Implement a polyfill for useReducer
useReducer can be built on useState: hold state in useState, and dispatch = a stable callback (useCallback/useRef) that calls setState(prev => reducer(prev, action)). Support the lazy init (third arg). The insight: useReducer is useState with the update logic centralized in a pure reducer.
useReducer is essentially useState with the update logic extracted into a pure reducer function — which means you can polyfill it with useState.
The polyfill
function useReducerPolyfill(reducer, initialArg, init) {
// support the lazy initializer (3rd arg), like the real hook
const [state, setState] = useState(
init !== undefined ? () => init(initialArg) : initialArg
);
// dispatch must be STABLE across renders — wrap in useCallback
const dispatch = useCallback((action) => {
// use the functional updater so we always reduce the LATEST state
setState((prev) => reducer(prev, action));
}, [reducer]);
return [state, dispatch];
}The pieces that matter
1. State is just useState. useReducer doesn't need new machinery — the state container is the same.
2. dispatch calls setState(prev => reducer(prev, action)). This is the core trick: the functional updater form of setState gives you the latest state, you run it through the reducer with the action, and the return value becomes the new state. The reducer being pure (state, action) => newState is what makes this clean.
3. dispatch must be stable. A defining property of the real useReducer is that dispatch has a stable identity across renders — so it's safe in dependency arrays and as a prop to memoized children without causing re-renders. Wrap it in useCallback. (Strictly, since reducer is usually defined inline and stable anyway, you could even use a ref to make dispatch truly never-changing — the real hook guarantees this.)
4. The lazy initializer. useReducer(reducer, initialArg, init) — if init is provided, initial state is init(initialArg), computed once. Pass it through useState's lazy initializer.
Why interviewers ask
It checks that you understand useReducer isn't magic — it's a thin convenience over useState that centralizes update logic in a testable pure function and gives you a stable dispatch. The functional-updater insight is the key.
When you'd actually use useReducer
Complex state with many sub-values, transitions that depend on the previous state, or when you want update logic in one pure, unit-testable place — and the stable dispatch is nice to pass down.
The framing
"useReducer is useState with the update logic pulled into a pure reducer. The polyfill: hold state in useState, and make dispatch a stable useCallback that calls setState(prev => reducer(prev, action)) — the functional updater is the trick, it feeds the latest state through the reducer. I'd also support the lazy init third argument via useState's lazy initializer. The properties to preserve are: pure reducer, and a stable dispatch identity so it's safe in deps and as a prop."
Follow-up questions
- •Why must dispatch have a stable identity?
- •Why use the functional updater form of setState here?
- •How does the lazy initializer (third argument) work?
- •When would you choose useReducer over useState?
Common mistakes
- •Not using the functional updater — closing over stale state.
- •Recreating dispatch every render so it's not stable.
- •Skipping the lazy-init third argument.
- •Putting side effects in the reducer — it must stay pure.
Performance considerations
- •Negligible overhead — it's useState plus a memoized callback. The stable dispatch is itself a perf feature: passing it to memoized children doesn't trigger re-renders.
Edge cases
- •Lazy initializer that's expensive — must run only once.
- •Reducer that returns the same state reference (no re-render).
- •Dispatching during render.
- •Actions dispatched in rapid succession — functional updater handles batching correctly.
Real-world examples
- •Form state with many fields and transitions managed by one reducer.
- •A reducer + context pattern for app-level state without Redux.