frontend / typescript / 08_react_typing.md

Typing React Components

6 min read source

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.

Interview Q&A

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

A: 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.

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>:

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

Q: ReactNode vs ReactElement vs JSX.Element — when each?

A:

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).

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

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

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.

Q: How do you type a forwardRef component?

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

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.

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

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

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>;
}

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

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

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.

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

A: 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.

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} />;
}

<Box as="a" href="/home">Home</Box>;       // href is type-checked against anchor
<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.

Q: How do you type DOM event handlers?

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

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:

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

Q: Type useState with a discriminated union.

A:

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.

Q: How do you make props mutually exclusive?

A: A discriminated union on props.

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.

Q: Type a custom hook.

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

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))[]:

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

  • “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