TypeScript With React Patterns

Common TypeScript patterns for React components, hooks, and props.

TL;DR

  1. 01Type props with interfaces or types for component APIs.
  2. 02Use React.FC<Props> or function return types for components.
  3. 03Extend event types from React for type-safe handlers.

Tips

  1. 01Use discriminated unions for any component that accepts fundamentally different prop shapes — it removes the need for runtime checks.

Warnings

  1. 01Avoid over-typing or using any — specific types prevent bugs and make refactoring safer.

Discriminated Union Props

  • Define a multi-variant component using a type union with a shared discriminant.

    type AlertProps =
      | { variant: "success"; message: string }
      | { variant: "error"; message: string; code: number };
    
    function Alert(props: AlertProps) {
      return <div className={props.variant}>{props.message}</div>;
    }
    
  • Access variant-specific props only after narrowing on the discriminant field.

    function Alert(props: AlertProps) {
      if (props.variant === "error") {
        console.log(props.code); // only available in "error" branch
      }
      return <div>{props.message}</div>;
    }
    
  • Use an exhaustive switch with never to catch unhandled variants at compile time.

    function renderBadge(props: BadgeProps): React.ReactNode {
      switch (props.variant) {
        case "count": return <span>{props.count}</span>;
        case "dot":   return <span className="dot" />;
        default: {
          const _never: never = props; // compile error if a variant is missing
          return null;
        }
      }
    }
    
  • Model a button that accepts either a label or an icon, but not both.

    type ButtonProps =
      | { kind: "label"; label: string }
      | { kind: "icon"; icon: React.ReactNode; ariaLabel: string };
    
    function Button(props: ButtonProps) {
      return props.kind === "label"
        ? <button>{props.label}</button>
        : <button aria-label={props.ariaLabel}>{props.icon}</button>;
    }
    
  • Require a message for error alerts but not for success alerts.

    type ToastProps =
      | { type: "success"; title: string }
      | { type: "error"; title: string; detail: string };
    
    // Callers must provide detail when type is "error"
    const t: ToastProps = { type: "error", title: "Failed", detail: "404" };
    

Polymorphic Components

  • Define helper types that extract the props for any HTML element or component.

    type AsProps<E extends React.ElementType> = {
      as?: E;
    } & React.ComponentPropsWithoutRef<E>;
    
  • Build a polymorphic Box component that types native props based on the as tag.

    function Box<E extends React.ElementType = "div">(
      { as, ...rest }: AsProps<E>
    ) {
      const Tag = as ?? "div";
      return <Tag {...rest} />;
    }
    
    // <Box as="a" href="/"> — href is required and typed
    // <Box as="button" onClick={fn}> — onClick is typed for button
    
  • Use React.ElementType as a prop type for flexible tag or component injection.

    interface HeadingProps {
      as?: React.ElementType; // accepts "h1", "h2", MyComponent, etc.
      children: React.ReactNode;
    }
    
    function Heading({ as: Tag = "h1", children }: HeadingProps) {
      return <Tag>{children}</Tag>;
    }
    
  • Avoid unsafe casting with as unknown — let generics flow through instead.

    // Bad: loses type safety
    function Box({ as: Tag = "div", ...rest }: any) {
      return <Tag {...(rest as any)} />;
    }
    
    // Good: generics preserve prop types
    function Box<E extends React.ElementType = "div">({ as, ...rest }: AsProps<E>) {
      const Tag = as ?? "div";
      return <Tag {...rest} />;
    }
    
  • Pass a custom component as the as prop to reuse any component's interface.

    function Link({ href, children }: { href: string; children: React.ReactNode }) {
      return <a href={href}>{children}</a>;
    }
    
    // Box as Link — Box now accepts href because Link does
    <Box as={Link} href="/home">Home</Box>
    

Forward Ref Patterns

  • Use React.forwardRef with explicit generics to type the ref and props correctly.

    const TextInput = React.forwardRef<
      HTMLInputElement,
      React.InputHTMLAttributes<HTMLInputElement>
    >((props, ref) => <input ref={ref} {...props} />);
    
  • Expose a custom handle interface using useImperativeHandle instead of the DOM ref.

    interface DialogHandle { open: () => void; close: () => void; }
    
    const Dialog = React.forwardRef<DialogHandle, { children: React.ReactNode }>(
      ({ children }, ref) => {
        const [open, setOpen] = useState(false);
        useImperativeHandle(ref, () => ({
          open: () => setOpen(true),
          close: () => setOpen(false)
        }));
        return open ? <div>{children}</div> : null;
      }
    );
    
  • Use the forwarded ref at the call site with useRef typed to the handle.

    const dialogRef = useRef<DialogHandle>(null);
    
    function Page() {
      return (
        <>
          <button onClick={() => dialogRef.current?.open()}>Open</button>
          <Dialog ref={dialogRef}>Hello</Dialog>
        </>
      );
    }
    
  • Forward refs in component libraries to let consumers control focus.

    const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(
      (props, ref) => <select ref={ref} {...props} />
    );
    
    // Consumers can now do: const ref = useRef<HTMLSelectElement>(null);
    
  • Assign displayName to forwardRef components for better DevTools labels.

    const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
      (props, ref) => <input ref={ref} {...props} />
    );
    Input.displayName = "Input";
    

Compound Components

  • Build a compound component by attaching child components as static properties.

    function Table({ children }: { children: React.ReactNode }) {
      return <table>{children}</table>;
    }
    
    Table.Row = function Row({ children }: { children: React.ReactNode }) {
      return <tr>{children}</tr>;
    };
    
    Table.Cell = function Cell({ children }: { children: React.ReactNode }) {
      return <td>{children}</td>;
    };
    
  • Use dot-notation at the call site to keep related components grouped.

    <Table>
      <Table.Row>
        <Table.Cell>Name</Table.Cell>
        <Table.Cell>Age</Table.Cell>
      </Table.Row>
    </Table>
    
  • Share state between compound parts using context.

    const AccordionCtx = React.createContext<{ open: string; setOpen: (id: string) => void } | null>(null);
    
    function Accordion({ children }: { children: React.ReactNode }) {
      const [open, setOpen] = useState("");
      return <AccordionCtx.Provider value={{ open, setOpen }}>{children}</AccordionCtx.Provider>;
    }
    
  • Type the static child properties explicitly on the parent component's type.

    interface TableComponent {
      (props: { children: React.ReactNode }): JSX.Element;
      Row: (props: { children: React.ReactNode }) => JSX.Element;
      Cell: (props: { children: React.ReactNode }) => JSX.Element;
    }
    
    const Table: TableComponent = ({ children }) => <table>{children}</table>;
    
  • Access shared context inside a child component to read parent state.

    Accordion.Item = function Item({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {
      const ctx = useContext(AccordionCtx)!;
      return (
        <div>
          <button onClick={() => ctx.setOpen(id)}>{title}</button>
          {ctx.open === id && children}
        </div>
      );
    };
    

Type Guards in Components

  • Write a type guard function to narrow a union type inside a component.

    interface User { kind: "user"; name: string; }
    interface Admin { kind: "admin"; name: string; permissions: string[]; }
    
    function isAdmin(person: User | Admin): person is Admin {
      return person.kind === "admin";
    }
    
  • Use the in operator as an inline type guard to check for a key's presence.

    function ProfileCard({ person }: { person: User | Admin }) {
      return (
        <div>
          <p>{person.name}</p>
          {"permissions" in person && <p>Permissions: {person.permissions.join(", ")}</p>}
        </div>
      );
    }
    
  • Narrow a discriminated union inside render using the kind field.

    function Notification(props: { type: "info"; text: string } | { type: "alert"; text: string; level: number }) {
      if (props.type === "alert") {
        return <div className={`alert-${props.level}`}>{props.text}</div>;
      }
      return <div>{props.text}</div>;
    }
    
  • Assert a type inside an event handler when you know the target element.

    function Form() {
      const handleChange = (e: React.ChangeEvent<HTMLElement>) => {
        if (e.target instanceof HTMLInputElement) {
          console.log(e.target.value); // now typed as string
        }
      };
      return <input onChange={handleChange} />;
    }
    
  • Use a type guard to safely render only the correct variant of a union prop.

    type Content = { kind: "text"; body: string } | { kind: "image"; src: string; alt: string };
    
    function isImageContent(c: Content): c is Extract<Content, { kind: "image" }> {
      return c.kind === "image";
    }
    
    function Render({ content }: { content: Content }) {
      return isImageContent(content)
        ? <img src={content.src} alt={content.alt} />
        : <p>{content.body}</p>;
    }
    

FAQ