Frontend / Typescript / 08_react_typing.md

Typing React Components

Updated 6 min read source
On this page6
  1. TL;DR
  2. In depth
  3. Gotchas / edge cases
  4. What a senior is expected to say
  5. Cross-references
  6. Further reading

Typing React Components

TL;DR

The day-job TypeScript: typing props, children, refs, events, hooks, and generic components — including the awkward intersections (forwardRef of a generic component, polymorphic as props, discriminated-union props that prevent invalid combinations). Senior interviewers probe these because they’re where typing actually breaks under real codebases.

In depth

How do you type a simple functional component’s props?

Don’t use React.FC for new code (it implicitly adds children, has historical quirks). Type props directly and use the function signature TS already infers.

tsx
type ButtonProps = { label: string; onClick: () => void; disabled?: boolean };

function Button({ label, onClick, disabled = false }: ButtonProps) {
  return <button onClick={onClick} disabled={disabled}>{label}</button>;
}

If you do want explicit children, use React.PropsWithChildren<P>:

tsx
type CardProps = React.PropsWithChildren<{ title: string }>;

ReactNode vs ReactElement vs JSX.Element — when each?

Type What it covers
ReactNode anything React can render — element, string, number, null, undefined, fragment, array of these. Use for children props.
ReactElement the object returned by JSX — { type, props, key }. Use as a return type when you specifically need an element (e.g. React.cloneElement).
JSX.Element a ReactElement with the implicit any for props — equivalent for component return types.

Rule of thumb: children: ReactNode, return type is whatever React infers (let it).

How do you get the props of an existing component (for wrapping)?

ComponentProps<typeof Button> for components, ComponentPropsWithoutRef<"button"> for intrinsic elements.

tsx
type NativeButtonProps = React.ComponentPropsWithoutRef<"button">;

function PrimaryButton(props: NativeButtonProps) {
  return <button {...props} className={`btn-primary ${props.className ?? ""}`} />;
}

Use ComponentPropsWithoutRef (or WithRef) deliberately — ComponentProps is WithRef aliased, which can confuse when you also call forwardRef outside.

How do you type a forwardRef component?

Two generic parameters — the ref element type first, then the props type:

tsx
type InputProps = React.ComponentPropsWithoutRef<"input"> & { label: string };

const Input = React.forwardRef<HTMLInputElement, InputProps>(
  function Input({ label, ...rest }, ref) {
    return (
      <label>
        {label}
        <input ref={ref} {...rest} />
      </label>
    );
  }
);

Named function expression (function Input(...)) makes the displayName work in React DevTools.

How do you type a generic component (List<T>)?

Same generic syntax as regular functions. Don’t wrap in forwardRef unless you must — forwardRef strips the generic.

tsx
type ListProps<T> = {
  items: T[];
  render: (item: T, index: number) => React.ReactNode;
};

function List<T>({ items, render }: ListProps<T>) {
  return <ul>{items.map((item, i) => <li key={i}>{render(item, i)}</li>)}</ul>;
}

// T inferred as number
<List items={[1, 2, 3]} render={(n) => n.toFixed(2)} />;

If you need both a generic and a ref, use the “as-cast workaround” (the standard idiom):

tsx
const List = React.forwardRef(function List<T>(
  { items, render }: ListProps<T>,
  ref: React.Ref<HTMLUListElement>,
) {
  return <ul ref={ref}>{items.map((item, i) => <li key={i}>{render(item, i)}</li>)}</ul>;
}) as <T>(p: ListProps<T> & { ref?: React.Ref<HTMLUListElement> }) => React.ReactElement;

The cast is the standard workaround; React 19 improves this for some cases, but the cast pattern is what you’ll see in production today.

What’s a polymorphic as prop, and how do you type it?

A component that can render as different elements/components (<Box as="a">, <Box as={Link}>). Typing it correctly requires a generic constrained to React.ElementType, plus ComponentPropsWithoutRef<C> to pull in the rendered element’s native props.

tsx
type BoxProps<C extends React.ElementType> = {
  as?: C;
} & Omit<React.ComponentPropsWithoutRef<C>, "as">;

function Box<C extends React.ElementType = "div">({
  as,
  ...rest
}: BoxProps<C>) {
  const Comp = (as ?? "div") as React.ElementType;
  return <Comp {...rest} />;
}

// href is type-checked against anchor
<Box as="a" href="/home">Home</Box>;
<Box as="button" onClick={() => {}}>Go</Box>;

Polymorphic typing has trade-offs — error messages get verbose; most teams use a battle-tested library type (Chakra’s As, Radix’s AsChild) instead of hand-rolling.

How do you type DOM event handlers?

Use React.<EventType> types, parameterized by the element:

tsx
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  console.log(e.target.value);
};

const onClick = (e: React.MouseEvent<HTMLButtonElement>) => { /* ... */ };
const onKey   = (e: React.KeyboardEvent<HTMLDivElement>) => { /* ... */ };

In a JSX prop position, TS already infers the right event type — only annotate when extracting the handler:

tsx
// e inferred
<input onChange={(e) => console.log(e.target.value)} />

Type useState with a discriminated union.

tsx
type State =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: Error };

const [state, setState] = React.useState<State>({ status: "idle" });

if (state.status === "success") {
  state.data;     // ok — narrowed
}

Discriminated unions are how you prevent “I have a data but no loading flag” type bugs at compile time.

How do you make props mutually exclusive?

A discriminated union on props.

tsx
type Props =
  | { icon: string; label?: never }
  | { label: string; icon?: never };

function IconOrLabel(props: Props) { /* ... */ }

<IconOrLabel icon="x" />;            // ok
<IconOrLabel label="x" />;           // ok
<IconOrLabel icon="x" label="x" />;  // error — can't have both

The ?: never is the trick that prevents the other property from being supplied.

Type a custom hook.

Return a tuple for “value + setter” patterns; an object for many fields. Annotate the return type explicitly for stable public API.

tsx
function useToggle(initial = false): [boolean, () => void] {
  const [on, setOn] = React.useState(initial);
  const toggle = React.useCallback(() => setOn((v) => !v), []);
  return [on, toggle];
}

as const on the returned array fixes a common bug where TS widens the tuple to (boolean | (() => void))[]:

tsx
return [on, toggle] as const;   // tuple preserved

Gotchas / edge cases

  • React.FC adds children implicitly — surprises when you don’t want children. Avoid for new code.
  • forwardRef strips generics. The cast workaround is the standard fix.
  • ComponentProps vs ComponentPropsWithoutRef — the first includes ref (relevant when wrapping forwardRef components); the latter is usually what you want for plain wrappers.
  • as-prop typing balloons error messages. Use a library or pre-canned pattern; don’t hand-roll if your team is small.
  • useState(null) infers null, not null | T — write useState<T | null>(null) explicitly.
  • useRef<HTMLDivElement>(null) returns RefObject<HTMLDivElement> with .current typed HTMLDivElement | null — narrow it before use.
  • onChange of <select> is ChangeEvent<HTMLSelectElement> — wrong element type is the most common copy-paste error.

What a senior is expected to say 5

  • “I avoid React.FC; the implicit children and historical quirks aren’t worth it. I type props directly.”
  • “When wrapping a component I use ComponentPropsWithoutRef<typeof X> so I’m explicit about ref behavior.”
  • forwardRef doesn’t compose well with generics; the cast workaround is standard. React 19 helps but isn’t universal yet.”
  • “Discriminated unions on useState and on props are what stops most of the ‘one valid field is undefined’ bugs at compile time.”
  • “I lean on React.ElementType and ComponentPropsWithoutRef<C> for polymorphic components — but I use a library type if the project already has one.”

Cross-references

Further reading