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.FCaddschildrenimplicitly — surprises when you don’t want children. Avoid for new code.forwardRefstrips generics. The cast workaround is the standard fix.ComponentPropsvsComponentPropsWithoutRef— the first includesref(relevant when wrappingforwardRefcomponents); 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)infersnull, notnull | T— writeuseState<T | null>(null)explicitly.useRef<HTMLDivElement>(null)returnsRefObject<HTMLDivElement>with.currenttypedHTMLDivElement | null— narrow it before use.onChangeof<select>isChangeEvent<HTMLSelectElement>— wrong element type is the most common copy-paste error.
What a senior is expected to say
- “I avoid
React.FC; the implicitchildrenand 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.” - “
forwardRefdoesn’t compose well with generics; the cast workaround is standard. React 19 helps but isn’t universal yet.” - “Discriminated unions on
useStateand on props are what stops most of the ‘one valid field is undefined’ bugs at compile time.” - “I lean on
React.ElementTypeandComponentPropsWithoutRef<C>for polymorphic components — but I use a library type if the project already has one.”
Cross-references
- Generics,
keyof, constraints: 01_generics.md - Discriminated unions and narrowing in depth: 05_narrowing_and_guards.md
- Strictness flags that affect
useStatewidening: 09_strictness_flags.md - React internals (Fiber, hooks): ../05_react/
Further reading
- React + TypeScript Cheatsheet: https://react-typescript-cheatsheet.netlify.app/
- TypeScript Handbook — JSX: https://www.typescriptlang.org/docs/handbook/jsx.html
- React 19
refas prop: https://react.dev/blog/2024/04/25/react-19#ref-as-a-prop