A React component calls const [state, dispatch] = useReducer(reducer, buildInitialState(items)), where buildInitialState is expensive. What happens on every re-render, and how do you fix it?
answer
- arguments evaluate before the call
- result discarded after the first render
- pass the function, not the call
- third argument receives the second
- same initializer powers a reset action
basics
~20 sbuildInitialState(items) is an ordinary argument, so JavaScript evaluates it on every render even though React only uses its result on the first one. Pass the raw value plus the function as useReducer's third argument so React calls the initializer only on mount.
solid answer
~40 sThe expensive call runs on every single render, because it is a normal function argument — JavaScript evaluates it before `useReducer` is even entered. React then throws the result away on every render after the first, since initial state is only read during the initial render. The fix is lazy initialization: pass the raw input as the second argument and the function as the third, `useReducer(reducer, items, buildInitialState)`. React calls `buildInitialState(items)` once, on mount, and never again. The initializer has to be pure for the same reason the reducer does — React deliberately calls initializers twice in development under StrictMode. The same trick gives you a clean reset: export the initializer, and have the reducer's `'reset'` case return `init(action.payload)` so the initial-state shape is defined in exactly one place.
code
javascript · 18 linesfunction buildInitialState(items) {
console.log('building');
return { byId: Object.fromEntries(items.map(i => [i.id, i])), selectedId: null };
}
function reducer(state, action) {
switch (action.type) {
case 'selected': return { ...state, selectedId: action.id };
case 'reset': return buildInitialState(action.items);
default: return state;
}
}
// Eager: logs 'building' on every render
// const [state, dispatch] = useReducer(reducer, buildInitialState(items));
// Lazy: React logs 'building' only on mount
// const [state, dispatch] = useReducer(reducer, items, buildInitialState);go deeper
Know the shape of the fix: pass the function itself as the third argument to useReducer instead of calling it inline. Same idea as passing a function to useState.
Explain the mechanism — arguments are evaluated before the call, so the work happens every render while React only uses it on the first — and show the corrected call.
Point out that lazy init is not a dependency mechanism, so a changed prop will not rebuild state, and name the honest alternatives: an explicit reset action or remounting with a key. Reuse the initializer in the reset case.
Judge when the third argument is worth the indirection at all, and set the convention: an exported, pure initializer as the single definition of initial state, reused by the reset path so the shape cannot drift.
## What actually happens `useReducer(reducer, buildInitialState(items))` contains a plain function call. JavaScript evaluates arguments before the call, so `buildInitialState(items)` executes on *every* render of the component — first render, and every re-render after it. React only looks at the initial-state argument during the initial render; on subsequent renders it returns the state it already has and discards the value you just spent time computing. So the cost is pure waste. If `buildInitialState` parses a large payload, builds an index from a list, or reads and deserializes from `localStorage`, you pay that cost on every keystroke that re-renders the component. ```js // runs on EVERY render, used only on the first const [state, dispatch] = useReducer(reducer, buildInitialState(items)); ``` ## The fix: the third argument `useReducer` accepts an optional third argument, an initializer function. When you pass it, React treats the second argument as raw input and calls `init(initialArg)` itself — only during the initial render: ```js // buildInitialState is CALLED BY REACT, once, on mount const [state, dispatch] = useReducer(reducer, items, buildInitialState); ``` Note the shape of the change: you removed the parentheses. You are handing React the function rather than the result. On re-renders React never invokes it, so the expensive work disappears from the render path entirely. The initializer receives whatever you passed as the second argument, which is how you keep it parameterized: ```js function buildInitialState(items) { return { byId: Object.fromEntries(items.map(i => [i.id, i])), selectedId: null }; } ``` ## The initializer must be pure Exactly like the reducer, the initializer runs during rendering, and React calls initializers twice in development under Strict Mode to surface impurity. So no network calls, no writes, no `Math.random()` sprinkled inside it. Reading synchronously from `localStorage` is a grey area many codebases accept — it is idempotent — but anything that *writes* or *sends* belongs elsewhere. ## The reset pattern this unlocks Once the initializer is a named, exported function, resetting to initial state stops being a second definition of the same shape. The reducer just calls it: ```js function reducer(state, action) { switch (action.type) { case 'reset': return buildInitialState(action.items); // ... } } // somewhere in the component dispatch({ type: 'reset', items }); ``` This matters in practice: without it, the initial-state literal appears twice — once at the `useReducer` call and once in the reset case — and they drift apart the first time someone adds a field. ## What lazy init does NOT do - **It is not a dependency mechanism.** If `items` changes later, React does not re-run the initializer; initial state means initial. If the component genuinely must rebuild its state when a prop changes, that is a different design question, and dispatching an explicit reset action is the honest answer. - **It is not memoization.** There is no cache and no comparison. React simply calls the function once, on mount, and stores the result as the first state. - **It is not required for cheap values.** `useReducer(reducer, { count: 0 })` allocates a small object per render; that is not worth a third argument. Reach for lazy init when the computation is genuinely expensive or touches something like storage. ## The parallel worth knowing `useState` has the same design: `useState(expensive())` calls `expensive` every render, while `useState(expensive)` — passing the function itself — makes React call it only on mount. Interviewers often ask the `useState` version first and then the `useReducer` version to see whether you understood the principle or memorised one API.
- If the items prop changes after mount, does React re-run the initializer?No. The initializer runs only during the initial render — "initial state" means initial, not "derived from the current props". If the component genuinely must rebuild its state when a prop changes, dispatch an explicit reset action carrying the new input, or remount the subtree with a different key. Silently expecting re-initialization is a common source of stale-state bugs.
- Does the same trap exist with useState?Yes, identically. `useState(buildInitial())` evaluates the call on every render and discards the result after the first. Passing the function itself — `useState(buildInitial)` — makes React call it lazily, once on mount. It is the same principle in both hooks: hand React the function, not the value it produces.
- Why must the initializer function be pure?It runs during rendering, and React calls initializers twice in development under Strict Mode specifically to expose impurity. An initializer that fires an analytics event or writes to storage would do it twice in development and potentially again whenever React re-renders. Keep it a pure computation from its argument to a state object.
saying these in an interview costs you the question
- The expensive call only runs on the first render anyway
- Lazy init re-runs whenever the argument changes
- The third argument is a memoization cache
- useMemo around the initial state is the fix
- Initializers may read and write storage freely