Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. TypeScript with React Components and Props

Table of contents

  • Share on X
  • Discuss on X

Related Articles

TypeScript Declaration Merging: Extend Contracts
TypeScript
11m
Nov 3, 2026

TypeScript Declaration Merging: Extend Contracts

Declaration merging can model extensible JavaScript APIs elegantly, but it only makes code safer when the runtime extension exists and the merged contract remains intentional.

#TypeScript#Declaration Merging+4
Writing Your Own Declaration Files in TypeScript
TypeScript
12m
Oct 27, 2026

Writing Your Own Declaration Files in TypeScript

A declaration file is an API design artifact. Learn to model JavaScript honestly, distinguish modules from globals, and test the contract consumers will actually import.

#TypeScript#Declaration Files+4
Using @types Packages in TypeScript
TypeScript
11m
Oct 20, 2026

Using @types Packages in TypeScript

@types packages are useful compatibility contracts for JavaScript libraries—provided their versions, visibility, and runtime behaviour stay aligned.

#TypeScript#@types+4
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • Props Are a Component Contract
  • Model Valid States With Unions
  • Compose Native Props Without Losing Meaning
  • Type Children and Events for Intent
  • Keep Generic Components Inferable
  • Design for Change, Then Verify the Contract
  • Key Takeaways
TypeScript

TypeScript with React Components and Props

November 10, 2026•9 min read
Joshua R. Lehman
Joshua R. Lehman
Author
Centred React component contract diagram connecting typed props, component variants, native element attributes, and rendered UI
TypeScript with React Components and Props

A React component’s props are its public API. They say what a caller may provide, which combinations are meaningful, what the component owns, and what it delegates to the DOM or another component. Weak prop types turn that contract into a bag of optional fields; callers can assemble impossible states and the component must defensively untangle them later.

Strong prop types do the opposite. They make the valid states easy to call, invalid combinations difficult to express, and component implementation simpler because the compiler carries useful information into each branch.

Props Are a Component Contract

Start with the component’s behaviour rather than a familiar helper type. Ask what it renders, which inputs change that rendering, which values are required together, and whether a caller is using a semantic element or a visual primitive.

type StatusBadgeProps = {
  status: "success" | "warning" | "error";
  children: React.ReactNode;
};
 
export function StatusBadge({ status, children }: StatusBadgeProps) {
  return <span data-status={status}>{children}</span>;
}

This compact contract is already useful: a caller cannot pass a misspelled visual state, and the implementation does not need to handle every arbitrary string. The goal is not maximal type cleverness. The goal is a prop surface that accurately represents the component’s supported decisions.

TypeScript checks JSX component attributes against the component’s props parameter. The TypeScript JSX handbook (opens in new tab) explains that custom components are value-based JSX elements while lowercase intrinsic elements draw their attributes from the JSX environment. That distinction is the basis for composing native prop types safely.

Model Valid States With Unions

Optional booleans often multiply states without documenting which combinations are valid. Consider a button that either navigates or performs an action:

type ButtonProps = {
  href?: string;
  onClick?: () => void;
  external?: boolean;
};

What should happen with href and onClick together? Is external meaningful without a URL? A discriminated union makes those decisions explicit:

type LinkButtonProps = {
  kind: "link";
  href: string;
  children: React.ReactNode;
};
 
type ActionButtonProps = {
  kind: "action";
  onPress: () => void;
  children: React.ReactNode;
};
 
type ButtonProps = LinkButtonProps | ActionButtonProps;
 
export function Button(props: ButtonProps) {
  if (props.kind === "link") {
    return <a href={props.href}>{props.children}</a>;
  }
 
  return <button onClick={props.onPress}>{props.children}</button>;
}

The discriminator narrows both the implementation and the call site. There is no ambiguity about which element, prop, and interaction the branch owns. Use this pattern when variants change required fields, semantics, or behaviour—not for every cosmetic class name.

For a component with a loading state, model whether the action remains available rather than hoping every caller remembers a convention:

type SaveButtonProps =
  | { state: "ready"; onPress: () => void; children: React.ReactNode }
  | { state: "saving"; children: React.ReactNode }
  | { state: "saved"; children: React.ReactNode };

The saving and saved branches cannot accidentally receive an action callback that the UI ignores. This is type design serving interaction design.

Compose Native Props Without Losing Meaning

When a component intentionally wraps a native element, reuse the native element’s attributes rather than hand-copying disabled, aria-*, event handlers, and future platform additions.

type PrimaryButtonProps = {
  tone?: "brand" | "danger";
} & React.ComponentPropsWithoutRef<"button">;
 
export function PrimaryButton({
  tone = "brand",
  className,
  ...buttonProps
}: PrimaryButtonProps) {
  return (
    <button
      {...buttonProps}
      className={`button button--${tone} ${className ?? ""}`}
    />
  );
}

ComponentPropsWithoutRef<"button"> communicates that this component forwards ordinary button props but does not expose a ref in this version. If it does forward a ref, use the corresponding ref-aware pattern and ensure the ref points to the element callers expect.

Omit or redefine native props only when your component truly changes their meaning. For example, a controlled input may own value and onChange as a domain-specific pair:

type CurrencyInputProps = Omit<
  React.ComponentPropsWithoutRef<"input">,
  "value" | "onChange" | "type"
> & {
  value: number | null;
  onValueChange: (value: number | null) => void;
};

This prevents a caller from accidentally passing a raw string value or a DOM change handler that conflicts with the component’s parsed currency contract. Do not use Omit mechanically; every omission should correspond to behaviour the wrapper owns.

Types Do Not Replace HTML Semantics

A prop type can allow an onClick, but it cannot make a clickable div keyboard-accessible, focusable, or semantically a button. Choose the right element first; use component types to preserve that meaning rather than paper over it.

Type Children and Events for Intent

React.ReactNode is a sensible default for content slots because it accepts the range of values React can render: elements, text, fragments, arrays, and conditionally absent content. Make children required when the component is meaningless without content, and optional only when an empty state is intentional.

type PanelProps = {
  title: string;
  children: React.ReactNode;
};

Resist promising a precise JSX child element type unless your runtime code validates and depends on it. React’s TypeScript guide (opens in new tab) notes an important limitation: TypeScript cannot reliably enforce that children are a particular JSX element type, such as only <li> elements. If a component needs structured items, prefer an explicit items data prop or a render callback.

type MenuProps<Item> = {
  items: readonly Item[];
  getKey: (item: Item) => React.Key;
  renderItem: (item: Item) => React.ReactNode;
};

For event handlers, infer from the element you render rather than reaching for any:

type SearchInputProps = {
  onQueryChange: (value: string) => void;
};
 
function SearchInput({ onQueryChange }: SearchInputProps) {
  return (
    <input
      onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
        onQueryChange(event.currentTarget.value);
      }}
    />
  );
}

Expose domain values to component consumers when the event object is only an implementation detail. It produces a smaller API and keeps consumers from depending on the wrapper’s internal element choice.

Keep Generic Components Inferable

Generic components are useful when a relationship between props matters: a table column knows the row shape, a select option returns the same value type it receives, or a list key corresponds to an item.

type SelectProps<Value> = {
  value: Value;
  options: readonly Value[];
  getLabel: (value: Value) => string;
  onValueChange: (value: Value) => void;
};
 
export function Select<Value>(props: SelectProps<Value>) {
  // render a controlled selection UI
  return null;
}

Inference works because Value appears in concrete positions across related props. Avoid placing a generic only in a callback return type or hiding it behind broad unknown; TypeScript then has little evidence to infer from and callers reach for explicit type arguments or assertions.

When a component supports polymorphic as rendering, start with a proven need. It expands the public contract considerably: props, refs, event types, accessibility responsibility, and collision resolution all vary with the chosen element. A separate LinkButton and Button are often more maintainable than one perfectly typed universal primitive. Generalize after repeated use has shown the stable abstraction.

Design for Change, Then Verify the Contract

The best component APIs make ordinary change local. A visual change should stay in the component. A new supported state should require an intentional addition to the contract. A consumer should not need to know whether an internal icon changed from SVG to text, or whether a field is rendered with one native element or another.

That is why names and ownership matter more than clever utility types. Compare these two callback shapes:

type LooseRowProps = {
  onClick?: (event: React.MouseEvent) => void;
};
 
type DomainRowProps = {
  onSelect: (customerId: string) => void;
};

The first exposes an implementation detail. It obliges every caller to learn that the row is clickable and makes it tempting to inspect DOM attributes or stop propagation. The second describes an application event. The component can later render a button, add keyboard support, or place the action inside another element without changing what its consumer understands.

The same rule applies to component state. If empty, loading, and error each need distinct content, make their rendering paths obvious instead of shipping a long list of booleans:

type ResultsProps =
  | { state: "loading" }
  | { state: "empty"; onClearFilters: () => void }
  | { state: "error"; message: string; onRetry: () => void }
  | { state: "ready"; children: React.ReactNode };
 
function Results(props: ResultsProps) {
  switch (props.state) {
    case "loading":
      return <p aria-live="polite">Loading results…</p>;
    case "empty":
      return <button onClick={props.onClearFilters}>Clear filters</button>;
    case "error":
      return (
        <button onClick={props.onRetry}>Try again: {props.message}</button>
      );
    case "ready":
      return <>{props.children}</>;
  }
}

This has a practical maintenance benefit: when someone adds a fifth state, the exhaustive switch becomes a review prompt. If it should be visually or semantically different, the implementation has one conspicuous place to answer that question.

Test the promises, not TypeScript itself

TypeScript is already checking whether a TypeScript caller supplies the required props. Tests should focus on the behaviour that remains at runtime: semantic output, interaction, accessibility, fallbacks, and the transitions around those typed states.

it("offers retry only for a failed request", async () => {
  const retry = vi.fn();
  render(
    <Results state="error" message="Network unavailable" onRetry={retry} />
  );
 
  await userEvent.click(screen.getByRole("button", { name: /try again/i }));
  expect(retry).toHaveBeenCalledOnce();
});

For a shared library, a small type-level test can still be valuable when an inferred generic or a discriminated union is part of the intended developer experience. Keep it close to a real use case. The question is not “can we outsmart the type system?” It is “would an accidental widening of this public API be caught before consumers feel it?”

A short review checklist

Before exporting a component, review it as if it belonged to another team:

  • Can a caller create a state the component does not meaningfully render?
  • Does the component preserve the semantics of the native element it wraps?
  • Are callbacks phrased in the consumer’s domain, rather than exposing DOM plumbing?
  • Is children flexible only where flexible content is actually supported?
  • Will the generic type be inferred from normal JSX usage?
  • Is unknown data validated before it reaches this typed surface?

If the answer is unclear, simplify the API before adding another conditional type. A small component with a precise contract scales farther than a universal primitive that asks every caller to understand its internals.

Validate at the Runtime Boundary

TypeScript protects component authors and TypeScript callers during development. It cannot validate CMS content, URL search params, JSON from an API, JavaScript consumers, or a caller using a cast. Parse untrusted data before passing it into a component that assumes a prop contract.

function isStatus(value: unknown): value is StatusBadgeProps["status"] {
  return value === "success" || value === "warning" || value === "error";
}

This separation is healthy: the component gets a simple, reliable status; the loader or adapter owns validation and a useful fallback. Do not make every visual component parse unknown data just because unknown data exists somewhere in the system.

Key Takeaways

  • Treat props as a public component contract: model supported behaviour, not a loose object of possibilities.
  • Use discriminated unions when variants change required values, semantics, or interactions.
  • Compose native element props when wrapping native elements, and omit fields only when the component truly owns a different contract.
  • Use ReactNode for flexible slots, explicit data or render props for structured children, and domain callbacks when DOM events are implementation details.
  • Keep generics inferable and add runtime validation where untrusted data enters—not inside every component.

Next, we return to Next.js Server Actions: privileged mutation boundaries that need validated inputs, authenticated actors, progressive enhancement, revalidation, and clear failure contracts.