Frontend / Typescript / 08_react_typing.md

Typing React Components

Updated 5 interview angles 6 min read source
On this page14
  • How do you type a simple functional component’s props?
  • ReactNode vs ReactElement vs JSX.Element — when each?
  • How do you get the props of an existing component (for wrapping)?
  • How do you type a forwardRef component?
  • How do you type a generic component (List)?
  • What’s a polymorphic as prop, and how do you type it?
  • How do you type DOM event handlers?
  • Type useState with a discriminated union.
  • How do you make props mutually exclusive?
  • Type a custom hook.
  • Gotchas
  • Related
  • Further reading
  • Interview angle
  • Typing React Components

    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.

    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

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

    Further reading

    Interview angle 5

    • “How do you type a component’s props?” - directly, with a props type on the parameter. Not React.FC: it historically injected an implicit children you did not ask for, and it complicates generic components for no benefit.
    • “How do you get the props of a component you are wrapping?” - ComponentPropsWithoutRef<typeof X>, or the WithRef variant when the wrapper genuinely forwards one. Choosing between the two is the point: it forces you to be explicit about ref behaviour rather than inheriting it by accident.
    • “What is the awkward corner?” - forwardRef does not compose with generics, because the returned type erases the type parameter. The standard workaround is casting the result to a hand-written generic signature. React 19 lets you take ref as an ordinary prop and avoid the whole thing, but not every codebase is there yet.
    • “What single typing habit prevents the most bugs?” - discriminated unions, on useState and on props. Modelling state as { status: "loading" } | { status: "ready"; data: T } makes “why is data undefined here” a compile error instead of a runtime one.
      • Follow-up: “How do you make two props mutually exclusive?” - a union of prop shapes where each branch sets the other’s key to never. It is the same idea applied to the component’s public API.
    • “How do you type a polymorphic as prop?” - React.ElementType for the tag plus ComponentPropsWithoutRef<C> for its props, generic in C. Getting it fully correct including refs is genuinely hard, so use the project’s existing library type if there is one rather than re-deriving it.