useReducer: state changes as a pure function
A reducer turns (state, action) into the next state. Why it must be pure, why it is named after Array.reduce, and when it beats several useState calls.
After this lesson you can
- Write a reducer that never mutates and returns state for unknown actions
- Explain why a reducer must be pure and how Strict Mode exposes one that is not
- Decide between useReducer and useState for a piece of state
When several pieces of state change together, or the next state depends
on rules that are worth testing on their own, move the logic out of the
component into a reducer: a function (state, action) => nextState.
function cartReducer(state: Cart, action: CartAction): Cart {
switch (action.type) {
case "add":
return { ...state, items: [...state.items, action.item] };
case "clear":
return { ...state, items: [] };
default:
return state;
}
}
function Cart() {
const [cart, dispatch] = useReducer(cartReducer, { items: [] });
return <button onClick={() => dispatch({ type: "clear" })}>Clear</button>;
}
The component now says what happened (dispatch({ type: "clear" })) and
the reducer decides what that means for the state. dispatch follows the
same rules as a state setter: it queues the action, the current render's
cart does not change, and React re-renders with the result. If the
reducer returns the very same state object, Object.is says nothing
changed and React can skip the re-render — which is why the default
branch returns state itself rather than a copy.
Why "reducer"
It is the same shape of function Array.prototype.reduce takes. Replaying
a list of actions through a reducer is literally a reduce call, which
also makes reducers trivial to unit test without rendering anything:
function counterReducer(state, action) { switch (action.type) { case "increment": return { count: state.count + 1 }; case "add": return { count: state.count + action.by }; case "reset": return { count: 0 }; default: return state; }} export function run() { const actions = [ { type: "increment" }, { type: "add", by: 10 }, { type: "typo" }, { type: "increment" }, ]; const initial = { count: 0 }; const final = actions.reduce(counterReducer, initial); const unknown = counterReducer(final, { type: "typo" }); return { final, initialUntouched: initial, unknownReturnsSameObject: unknown === final };}Pure, or it breaks
React may call a reducer more than once for the same action — in
development Strict Mode it deliberately calls it twice and keeps one
result. A pure reducer gives the same answer both times. One that
mutates its input, reads a counter, calls Date.now() or makes a request
does not:
let nextId = 1;function impureReducer(state, action) { if (action.type === "add") return [...state, { id: nextId++, text: action.text }]; return state;}function pureReducer(state, action) { if (action.type === "add") return [...state, { id: action.id, text: action.text }]; return state;} // Strict Mode in development: call twice, keep the second result.function strictDispatch(reducer, state, action) { reducer(state, action); return reducer(state, action);} export function run() { let impure = []; impure = strictDispatch(impureReducer, impure, { type: "add", text: "milk" }); impure = strictDispatch(impureReducer, impure, { type: "add", text: "eggs" }); let pure = []; pure = strictDispatch(pureReducer, pure, { type: "add", id: 1, text: "milk" }); pure = strictDispatch(pureReducer, pure, { type: "add", id: 2, text: "eggs" }); return { impureIds: impure.map((t) => t.id), pureIds: pure.map((t) => t.id) };}In this model the impure version handed out ids 2 and 4 for two adds, because each action was processed twice and the outer counter moved twice. In a real app the exact numbers depend on how often React calls the reducer; what matters is that they are not 1 and 2. Generate ids, timestamps and random values in the event handler and put them in the action; the reducer only reads them.
useReducer or useState
Both are fine. Reach for a reducer when one event updates several fields
together, when the transitions are complex enough to deserve tests, or
when many handlers would otherwise repeat the same update logic. Keep
useState for independent values like an open/closed flag.
Common mistakes
- Mutating
stateand returning it. The returned reference is the old one, so React may skip the render, and Strict Mode's second call sees already-mutated input. - Side effects in the reducer (fetching, logging analytics, generating ids). Strict Mode runs them twice in development, as the second example models, and they run at a time React chooses.
- Forgetting a
defaultbranch. The reducer returnsundefinedfor an unknown action and the state disappears. Returnstate, or throw to catch typos early.
What interviewers ask
- When would you choose
useReduceroveruseState? — When updates to several related fields happen together or follow non-trivial rules; the reducer is a pure function that can be tested in isolation. - Why must a reducer be pure? — React may call it more than once (Strict Mode does so on purpose) and only keeps one result; purity makes repeated calls harmless and the state predictable.
Exercise · Pro
A shopping cart reducer
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade