React & TypeScript
Typing a React codebase — component props and why React.FC fell out of favour, useState with a null initial value, custom hooks that return tuples, discriminated prop unions for component variants, reducers, and form-error shapes.
10 questions
JuniorTheoryVery commonHow do you type a React component's props, and why is the helper type React.FC discouraged?
How do you type a React component's props, and why is the helper type React.FC discouraged?
Type the props object directly: declare an interface Props, then annotate the parameter — function Card(props: Props). React.FC fell out of favour because it implicitly added a children prop the component may not accept, and it cannot express a generic component: the alias has no type parameter of its own.
Common mistakes
- ✗Reaching for
React.FC<Props>out of habit and inheriting an implicitchildrenprop - ✗Assuming props can be inferred from JSX usage instead of being declared
- ✗Expecting a component typed as
React.FCto still take its own type parameter
Follow-up questions
- →How do you declare
childrenexplicitly once you stop usingReact.FC? - →How would you type a generic list component that infers its item type from a prop?
JuniorTheoryVery commonHow do you type useState when the initial value is null but the state will later hold a User?
How do you type useState when the initial value is null but the state will later hold a User?
Pass the type argument explicitly: useState<User | null>(null). Left to inference, useState(null) types the state as null alone, so a later setUser(user) is a type error. The explicit union also forces every read to narrow before it can reach user.name.
Common mistakes
- ✗Letting
useState(null)infer the state asnull, so the setter rejects a real value - ✗Asserting the initial
nullup toUserand losing every null check downstream - ✗Expecting the setter's parameter type to be wider than the declared state type
Follow-up questions
- →Why does
useState<User | null>(null)force a narrowing check at every read? - →How does the lazy initializer form
useState(() => load())change the inferred type?
MiddleCodeCommonTyping a custom hook that returns a useState-style tuple
Typing a custom hook that returns a useState-style tuple
Pin the tuple with as const — return [on, toggle] as const — or annotate the return type as [boolean, () => void]. Without one of the two, TypeScript widens the array literal to (boolean | (() => void))[], and destructuring hands the caller that union in both slots.
Common mistakes
- ✗Expecting
return [on, toggle]to infer a tuple instead of widening to an array - ✗Reaching for a call-site assertion instead of fixing the hook's own return type
- ✗Believing
as constmakes the returned function itself unusable as a callback
Follow-up questions
- →Why does
useStateitself return a tuple rather than an array of a union? - →When is returning a named object preferable to a tuple from a custom hook?
MiddleCodeCommonMaking two mutually-exclusive component props impossible to pass together
Making two mutually-exclusive component props impossible to pass together
Model the props as a union discriminated by a literal field, not one object with optional members: { variant: 'link'; href: string } | { variant: 'button'; onClick(): void }. Optional props make every combination legal; the union reaches href only after narrowing on variant, so invalid mixes stop compiling.
Common mistakes
- ✗Expressing variants as optional props on one interface, which makes every mix legal
- ✗Believing TypeScript cannot express mutually exclusive properties at compile time
- ✗Omitting the literal discriminant, so narrowing inside the component has nothing to test
Follow-up questions
- →How does the compiler narrow
propsinside the component once the union is discriminated? - →How would you extend this to a third variant without touching the existing two?
MiddleCodeCommonTyping a data-fetching hook's idle / loading / success / error states
Typing a data-fetching hook's idle / loading / success / error states
Make the state one discriminated union — {status:'idle'} | {status:'loading'} | {status:'success'; data:T} | {status:'error'; error:Error} — not a flat object with optional fields. Narrowing on status reaches data only in the success branch, so loading-with-data becomes inexpressible.
Common mistakes
- ✗Modelling four states as one object with optional fields, which admits impossible combinations
- ✗Reaching for
data!in the success branch instead of letting narrowing prove it is present - ✗Assuming a generic
Tcannot live on only one member of a discriminated union
Follow-up questions
- →Why is
status: 'loading'with a populateddatafield a bug the flat type cannot catch? - →How would you add a
refetchingstate that keeps the previousdatavisible?
MiddleDesignOccasionalA signup form has nested fields — profile.firstName, address.city — and your team must decide how to type its validation errors. One proposal is a flat Record<string, string> keyed by dotted paths such as profile.firstName. The other is an error type derived from the form's own shape, so that the error object mirrors the form object. Both compile. Explain which one you would ship, what each does when a field is later renamed, and why one of the two turns a rename into a compile error while the other silently keeps a stale key alive.
A signup form has nested fields — profile.firstName, address.city — and your team must decide how to type its validation errors. One proposal is a flat Record<string, string> keyed by dotted paths such as profile.firstName. The other is an error type derived from the form's own shape, so that the error object mirrors the form object. Both compile. Explain which one you would ship, what each does when a field is later renamed, and why one of the two turns a rename into a compile error while the other silently keeps a stale key alive.
Derive the error type from the form's own shape — type Errors<T> = { [K in keyof T]?: T[K] extends object ? Errors<T[K]> : string } — so the two are one declaration, not two. A flat Record<string, string> of dotted paths compiles too, but its keys are unchecked strings: rename a field and its error key silently rots.
Common mistakes
- ✗Treating a dotted string key as something the compiler checks against the form type
- ✗Assuming a mapped type cannot recurse into a nested object shape
- ✗Believing a type has a bundle cost, when it is erased before emit
Follow-up questions
- →How would you also surface a form-level error that belongs to no single field?
- →What does the mirrored error type give a component that renders one field at a time?
MiddleDesignOccasionalYour team is choosing between a Redux Toolkit createSlice, which infers the action types from the reducer map you hand it, and a hand-written action union plus a plain switch reducer. Both give a typed store. Explain which declaration owns the action types in each approach, what can drift out of sync in one and cannot in the other, and what the hand-written union still buys you that the inferred one does not.
Your team is choosing between a Redux Toolkit createSlice, which infers the action types from the reducer map you hand it, and a hand-written action union plus a plain switch reducer. Both give a typed store. Explain which declaration owns the action types in each approach, what can drift out of sync in one and cannot in the other, and what the hand-written union still buys you that the inferred one does not.
createSlice derives the action types from the reducer map itself, so the union and the creators are projections of one declaration and cannot drift from the reducers. A hand-written union is a second source of truth kept in sync by hand: it buys explicitness and lets you name an action no reducer handles yet.
Common mistakes
- ✗Thinking
createSliceconsumes an action union rather than producing one - ✗Believing a hand-written action union cannot be exhaustively checked in a
switch - ✗Missing that a second declaration of the action names is exactly what can drift
Follow-up questions
- →How do you recover the action union from a slice when middleware needs to match on it?
- →When is naming an action that no reducer yet handles actually useful?
MiddleDesignOccasionalYou are typing a client-side store for a dashboard — Redux Toolkit or Zustand, the library is not the point. The store holds the loaded rows, a loading flag and a filter object, and it exposes several actions that change them. A colleague has typed the whole thing as one interface in which every action sits next to the data as an optional method. Explain how you would separate the state type from the action type, what belongs in each, and why folding both into one interface hurts the reducer's exhaustiveness checking and makes state fixtures painful to write in tests.
You are typing a client-side store for a dashboard — Redux Toolkit or Zustand, the library is not the point. The store holds the loaded rows, a loading flag and a filter object, and it exposes several actions that change them. A colleague has typed the whole thing as one interface in which every action sits next to the data as an optional method. Explain how you would separate the state type from the action type, what belongs in each, and why folding both into one interface hurts the reducer's exhaustiveness checking and makes state fixtures painful to write in tests.
Keep the state a plain data shape with no functions in it, and describe the actions separately — a discriminated union of { type, payload } objects, or their own slice of the store. One merged interface makes each action an optional member beside the data, so the reducer's switch has no closed union to be exhaustive over.
Common mistakes
- ✗Putting action methods inside the state interface, so the data shape can never be constructed plainly
- ✗Typing actions as a map of handlers rather than a closed discriminated union
- ✗Expecting a
defaultbranch that throws to give the same guarantee as a compile-time exhaustiveness check
Follow-up questions
- →What does the reducer's
neverexhaustiveness check give you that a throwndefaultdoes not? - →How does the split change what a selector's return type is inferred from?
SeniorDesignOccasionalYou own a shared component library used by six product teams. Consumers keep re-declaring props your components already accept — aria-label, disabled, onFocus — and keep passing variant="primary-large" strings you never intended to support. The library also ships form fields that must render a validation error supplied by the app. Explain how you would type the public props of these components so that native DOM attributes pass through without being re-declared, so that only the variants you support compile, and so that an app can reuse your types rather than copy them.
You own a shared component library used by six product teams. Consumers keep re-declaring props your components already accept — aria-label, disabled, onFocus — and keep passing variant="primary-large" strings you never intended to support. The library also ships form fields that must render a validation error supplied by the app. Explain how you would type the public props of these components so that native DOM attributes pass through without being re-declared, so that only the variants you support compile, and so that an app can reuse your types rather than copy them.
Extend the underlying element's props — interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> — so every DOM attribute comes for free and stays in sync with React's typings. Constrain your own additions to literal unions, not string, so an unsupported variant fails to compile. Then export the prop types, including a field's error shape, so apps compose against them.
Common mistakes
- ✗Re-declaring DOM attributes by hand instead of extending the element's own props type
- ✗Typing a variant as
string, which accepts every value the library does not support - ✗Keeping the prop types private, so each consumer restates them and they drift
Follow-up questions
- →How does
ComponentPropsWithRefchange the contract when the component forwards aref? - →How would you let a consumer render your button as an
<a>without losing either element's attributes?
SeniorDesignOccasionalA useReducer reducer in your app has grown to twenty action types and its switch is now four hundred lines that nobody wants to open. A colleague proposes replacing the action union with a loose { type: string; payload?: unknown } and dispatching handlers from a lookup object, to make the file shorter. Explain what that would cost, and describe how you would keep the reducer both readable and fully typed — including what has to stay in place for a forgotten action to remain a compile error rather than a silent no-op.
A useReducer reducer in your app has grown to twenty action types and its switch is now four hundred lines that nobody wants to open. A colleague proposes replacing the action union with a loose { type: string; payload?: unknown } and dispatching handlers from a lookup object, to make the file shorter. Explain what that would cost, and describe how you would keep the reducer both readable and fully typed — including what has to stay in place for a forgotten action to remain a compile error rather than a silent no-op.
Keep the discriminated action union as the single source of truth and split the body, not the type: give each action a handler typed Extract<Action, { type: 'x' }>, so it sees only its own payload. Keep the never check in default — that one line turns a forgotten action into a compile error. { type: string } erases both.
Common mistakes
- ✗Trading the action union for
{ type: string }and losing every payload type with it - ✗Dropping the
neverexhaustiveness check, so a forgotten action becomes a silent no-op - ✗Assuming a handler lookup object can check action names as strictly as a discriminated union
Follow-up questions
- →How does
Extract<Action, { type: 'x' }>give a handler exactly one action's payload? - →Why does assigning the narrowed action to
nevercatch a newly added action type?