Skip to content

TypeScript8 min read

TypeScript for React Developers: The Practical Guide

TypeScript for React developers: how to type props, children, events, refs, hooks and generic components, plus utility types and discriminated unions.

TypeScript for React mostly comes down to five things: typing props (including children), typing events, typing hooks and refs, writing generic components, and modelling variants with discriminated unions. Type the props parameter directly, use React.ReactNode for children, borrow native element props with React.ComponentProps<"button">, and let inference do the rest. The examples below target React 19 with current @types/react, and they compile with TypeScript 7.0, the new native compiler, the same way they do with 5.x.

Key takeaways

  • Type props as a plain object type on the function parameter. React.FC is optional and cannot express generic components.
  • React.ReactNode is the right default for children.
  • Extend native elements with React.ComponentProps<"button"> and Omit to avoid retyping every attribute.
  • In React 19, ref is a normal prop, so forwardRef is no longer needed.
  • Generic components (<T,>) keep lists, tables and selects type-safe without any.
  • Discriminated unions make impossible prop combinations and impossible states fail at compile time.

Typing React props and children

TSX
import type { ReactNode } from "react";

type CardProps = {
  title: string;
  subtitle?: string;               // optional
  variant?: "default" | "outlined"; // string literal union
  children: ReactNode;
};

export function Card({ title, subtitle, variant = "default", children }: CardProps) {
  return (
    <section className={`card card--${variant}`}>
      <h2>{title}</h2>
      {subtitle && <p>{subtitle}</p>}
      {children}
    </section>
  );
}

That is the whole pattern for most components. A few notes:

  • type or interface? Both work for props. I default to type because props often need unions, which interfaces cannot express.
  • Default values go in the destructuring, and TypeScript narrows variant to non-undefined inside the function.
  • React.FC used to add an implicit children prop, which caused bugs. That was removed in React 18's types, so it is harmless now, but typing the parameter directly is simpler and works for generics.

Which type for children?

Type Accepts Use when
ReactNode Elements, strings, numbers, arrays, null, undefined, booleans Default for almost every component
ReactElement A single JSX element You call cloneElement or need element props
(value: T) => ReactNode A function Render props
PropsWithChildren<P> Adds children?: ReactNode to P Shorthand when children are optional

Avoid JSX.Element for children: it rejects strings and null, which are perfectly valid children.

Extending native HTML element props

Do not retype onClick, disabled, type and 200 other attributes. Borrow them.

TSX
import type { ComponentProps } from "react";

type ButtonProps = ComponentProps<"button"> & {
  variant?: "primary" | "ghost";
  loading?: boolean;
};

export function Button({ variant = "primary", loading = false, children, disabled, ...rest }: ButtonProps) {
  return (
    <button {...rest} disabled={disabled || loading} data-variant={variant}>
      {loading ? "Please wait..." : children}
    </button>
  );
}

// Override a native prop's type with Omit
type TextInputProps = Omit<ComponentProps<"input">, "onChange" | "size"> & {
  onChange: (value: string) => void;
  size?: "sm" | "md" | "lg";
};

export function TextInput({ onChange, size = "md", ...rest }: TextInputProps) {
  return <input {...rest} data-size={size} onChange={(e) => onChange(e.target.value)} />;
}

ComponentProps<"input"> includes ref in React 19, so TextInput accepts a ref with no extra work, since ref arrives in rest and gets spread onto the input. To get the props of another component, use ComponentProps<typeof Button>.

Typing events in React TypeScript

Inline handlers infer their event type, so you only need explicit types when the handler is defined separately.

TSX
import { useState, type ChangeEvent, type KeyboardEvent, type MouseEvent, type SubmitEvent } from "react";

export function SearchForm({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState("");

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value);
  }

  function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    if (e.key === "Escape") setQuery("");
  }

  function handleSubmit(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    onSearch(query);
  }

  function handleClear(e: MouseEvent<HTMLButtonElement>) {
    e.currentTarget.blur();
    setQuery("");
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={query} onChange={handleChange} onKeyDown={handleKeyDown} />
      <button type="button" onClick={handleClear}>Clear</button>
      <button type="submit">Search</button>
    </form>
  );
}

A recent change worth knowing: newer @types/react 19 releases mark React.FormEvent as deprecated (the DOM has no "form event") and add SubmitEvent for onSubmit and InputEvent for onInput. FormEvent still compiles, but your editor will strike it through. On older type versions, keep using FormEvent<HTMLFormElement> for submit handlers.

Quick reference:

Handler Event type
onChange on input, select, textarea ChangeEvent<HTMLInputElement> (or the matching element)
onClick MouseEvent<HTMLButtonElement>
onKeyDown KeyboardEvent<HTMLInputElement>
onSubmit SubmitEvent<HTMLFormElement> (FormEvent on older types)
onFocus / onBlur FocusEvent<HTMLInputElement>

You can also type the whole handler: const onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => ....

Typing hooks: useState, useRef and useReducer

TSX
import { useEffect, useReducer, useRef, useState } from "react";

type User = { id: string; name: string };

export function Profile() {
  // Inferred as boolean
  const [open, setOpen] = useState(false);
  // Explicit when the initial value does not show the full type
  const [user, setUser] = useState<User | null>(null);
  // DOM ref: pass null, get RefObject<HTMLInputElement | null>
  const inputRef = useRef<HTMLInputElement>(null);
  // Mutable value ref
  const renders = useRef(0);

  useEffect(() => {
    renders.current += 1;
    inputRef.current?.focus();
  });

  return (
    <div>
      <input ref={inputRef} />
      <button onClick={() => setOpen((o) => !o)}>{open ? "Hide" : "Show"}</button>
      {open && <p>{user ? user.name : "Guest"}</p>}
      <button onClick={() => setUser({ id: "1", name: "Sita" })}>Log in</button>
    </div>
  );
}

type CounterAction = { type: "increment" } | { type: "add"; amount: number } | { type: "reset" };

function counterReducer(state: number, action: CounterAction): number {
  switch (action.type) {
    case "increment":
      return state + 1;
    case "add":
      return state + action.amount;
    case "reset":
      return 0;
  }
}

export function Counter() {
  const [count, dispatch] = useReducer(counterReducer, 0);
  return <button onClick={() => dispatch({ type: "add", amount: 5 })}>{count}</button>;
}

In React 19's types, useRef always needs an argument, and refs are mutable by default. The old split between MutableRefObject and read-only RefObject is gone.

For custom hooks, return tuples as const so TypeScript keeps the positions typed, or return an object. More patterns in custom React hooks with examples.

Generic React components

Generic components keep the item type flowing from props to callbacks. This is where any usually creeps in.

TSX
import type { Key, ReactNode } from "react";

type ListProps<T> = {
  items: T[];
  getKey: (item: T) => Key;
  renderItem: (item: T) => ReactNode;
  emptyText?: string;
};

export function List<T>({ items, getKey, renderItem, emptyText = "Nothing here" }: ListProps<T>) {
  if (items.length === 0) return <p>{emptyText}</p>;
  return (
    <ul>
      {items.map((item) => (
        <li key={getKey(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

// Usage: T is inferred as { id: number; name: string }
export function Team() {
  const members = [
    { id: 1, name: "Asha" },
    { id: 2, name: "Bikash" },
  ];
  return <List items={members} getKey={(m) => m.id} renderItem={(m) => <strong>{m.name}</strong>} />;
}

If you write the component as an arrow function in a .tsx file, use <T,> so the parser does not read <T> as a JSX tag: const List = <T,>(props: ListProps<T>) => .... Add constraints when you need them, such as <T extends { id: string }>.

Utility types React developers actually use

Utility What it does React example
Partial<T> Makes all properties optional Patch payloads, form drafts
Required<T> Makes all properties required Resolved config after defaults
Pick<T, K> Keeps listed keys Pick<User, "id" | "name"> for a list row
Omit<T, K> Removes listed keys Overriding native props (above)
Record<K, V> Object with keys K and values V Record<Status, string> for label maps
ReturnType<typeof fn> Return type of a function Types from a store or hook
Awaited<T> Unwraps a Promise Awaited<ReturnType<typeof fetchUser>>
ComponentProps<C> Props of an element or component Wrapping third-party components

Pair Record with satisfies to check an object without widening it:

TypeScript
type Status = "idle" | "loading" | "success" | "error";

const statusLabel = {
  idle: "Waiting",
  loading: "Loading...",
  success: "Done",
  error: "Something went wrong",
} satisfies Record<Status, string>;
// Forget a key, and TypeScript errors. Keys stay as literal types.

Discriminated unions for props and state

A discriminated union is a union of object types that share a literal "tag" property. TypeScript uses the tag to narrow the type. It is the most useful TypeScript pattern in React.

Props that depend on each other

TSX
type LinkButtonProps =
  | { as: "button"; onClick: () => void; href?: never }
  | { as: "link"; href: string; onClick?: never };

export function Action(props: LinkButtonProps & { label: string }) {
  if (props.as === "link") {
    return <a href={props.href}>{props.label}</a>;
  }
  return <button onClick={props.onClick}>{props.label}</button>;
}

// <Action as="link" href="/about" label="About" />   OK
// <Action as="link" onClick={() => {}} label="x" />  Error: href is missing

The ?: never fields block the wrong combination outright.

State that cannot be inconsistent

TSX
type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };

function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}

export function UserView({ state }: { state: FetchState<{ name: string }> }) {
  switch (state.status) {
    case "idle":
      return null;
    case "loading":
      return <p>Loading...</p>;
    case "success":
      return <p>Hello, {state.data.name}</p>;
    case "error":
      return <p role="alert">{state.error}</p>;
    default:
      return assertNever(state);
  }
}

Compare that with { isLoading: boolean; error?: string; data?: T }, where "loading with an error and data" is a valid type. With the union, it is not. Add a new status later and assertNever turns every unhandled switch into a compile error. For server data in real apps, TanStack Query gives you the same narrowing out of the box (see the TanStack Query guide).

Common TypeScript mistakes in React

  • any for event handlers or API data. Use unknown for untrusted data and validate it, for example with Zod (covered in React Hook Form + Zod).
  • Type assertions instead of narrowing. data as User silences the compiler without checking anything.
  • Optional everything. Five optional props that only make sense together should be a discriminated union.
  • JSX.Element for children. It rejects strings and null. Use ReactNode.
  • Duplicating types the library already exports. ComponentProps, z.infer, and ReturnType usually give you the type for free.
  • Turning off strict. Most of the value comes from strict null checks.

If you learn only one pattern from this post, make it discriminated unions for loading state: it turns a whole category of runtime bugs into red squiggles in your editor. For the full reference, the TypeScript handbook on narrowing is worth reading end to end.

FAQ

What type should I use for children in React?

Use React.ReactNode for children that can be anything React can render: elements, strings, numbers, arrays, null or undefined. Use a narrower type such as React.ReactElement or a render function type only when the component really needs it.

Should I use type or interface for React props?

Either works. Many teams use type for props because it handles unions and intersections, which you need for discriminated union props. Pick one convention and apply it consistently.

Do I still need forwardRef in React 19 with TypeScript?

No. In React 19, function components receive ref as a regular prop. Type it as part of your props, for example with React.ComponentProps<'input'>, which already includes ref.

How do I type an onChange event in React TypeScript?

Use React.ChangeEvent with the element type, for example (e: React.ChangeEvent) => void, or type the handler with React.ChangeEventHandler. Inline handlers get the type inferred automatically.

It is no longer harmful since it stopped adding implicit children, but most codebases type the props parameter directly instead. That reads more simply and works the same way for generic components, which React.FC cannot express.

Wrapping up

You do not need advanced type gymnastics to get most of TypeScript's value in React. Type props directly, reuse native element props, let inference handle hooks and inline events, and reach for generics and discriminated unions when components get more flexible. Those habits catch the bugs that matter before they reach users.

  • #typescript
  • #react
  • #types
  • #generics