I3 · Part 2 · Intermediate
useReducer
When the next state depends on the current state and on which thing happened.
12On this page
- The shape
- Why bother, when useState exists
- The example that makes the case
- Naming actions after events, not setters
- Why purity is enforced, not suggested
- Where the side effects go
- Testing it
- Lazy initialisation
- dispatch is stable, and that matters
- Seeing every action while debugging
- Reducer plus context: the poor man's store
- Keeping it immutable
The shape
function reducer(state, action) {
switch (action.type) {
case "increment": return { ...state, count: state.count + 1 };
case "reset": return { count: 0 };
default: throw new Error("Unknown action: " + action.type);
}
}
const [state, dispatch] = useReducer(reducer, { count: 0 });
dispatch({ type: "increment" });
A reducer is a pure function: given the current state and a description of what happened, it returns the next state. It must not mutate its argument, and it must not fetch, log, or set timers — React may call it more than once for a single dispatch.
Like a state setter, dispatch does not change
state in the code that is already running. Log
state right after dispatching and you see the old value; the new
one arrives in the next render.
Why bother, when useState exists
useState is a reducer underneath. Reach for the explicit one when
one of these is true:
- Several fields move together. Starting a request sets
loading, clearserrorand clearsdata— three setters that must never be out of step. - The next state depends on the current one in more than a trivial way.
- The same transition happens from several places. The logic lives in one function instead of being duplicated in four handlers.
- You want to test the logic. A reducer is a plain function — call it with a state and an action and assert on the result. No component, no render.
| Situation | Choose |
|---|---|
| A toggle, an input's text, one counter | useState |
| Two or three unrelated values | Separate useState calls |
| A request's status, data and error | useReducer |
| A multi-step form or wizard | useReducer |
| Logic a teammate must be able to unit test | useReducer |
The example that makes the case
// with useState: four setters, and every caller must remember all of them
setLoading(true); setError(null); setData(null);
// with a reducer: one dispatch, and the transition is defined once
dispatch({ type: "fetch/start" });
function reducer(state, action) {
switch (action.type) {
case "fetch/start": return { status: "loading", data: null, error: null };
case "fetch/success": return { status: "ready", data: action.data, error: null };
case "fetch/failure": return { status: "error", data: null, error: action.error };
default: return state;
}
}
Notice what became impossible: there is no way to be
loading and hold an error at the same time, because
no transition produces that. With four independent booleans, every impossible
combination is one forgotten setter away.
Make impossible states impossible
Four booleans give sixteen combinations, of which perhaps four are valid.
One status field with four values gives exactly four. This is
the strongest argument for reducers and it has nothing to do with
performance — it is about the states your UI can never accidentally
reach.
Naming actions after events, not setters
dispatch({ type: "setCount", value: 5 }); // ✗ a setter in disguise
dispatch({ type: "incremented" }); // ✓ what happened
dispatch({ type: "quantityChanged", id, qty }); // ✓
Actions describe events, not assignments. That way one action can drive several changes, and reading the list of action types tells you what the feature does — it becomes the documentation of the component's behaviour.
Why purity is enforced, not suggested
case "added":
state.items.push(action.item); // ✗ mutates the current state
return { ...state };
In development, Strict Mode calls your reducer twice for each dispatch to
flush out exactly this. The first call pushes the item into the existing
array, the second pushes it again, and the list shows it twice. It looks like
a React bug; it is a mutation that production would have hidden until
something compared old and new state. Build the new array instead:
items: [...state.items, action.item].
Where the side effects go
async function handleSave() {
dispatch({ type: "save/start" });
try {
const saved = await api.save(state.draft);
dispatch({ type: "save/success", saved });
} catch (error) {
dispatch({ type: "save/failure", error });
}
}
A reducer cannot await, so the async work happens in the event handler and the reducer only records what happened at each step. The reducer stays pure and testable; the handler owns the request. That split is the same one Redux thunks formalise.
Testing it
test("start clears a previous error", () => {
const before = { status: "error", data: null, error: "timeout" };
const after = reducer(before, { type: "fetch/start" });
expect(after).toEqual({ status: "loading", data: null, error: null });
});
No render, no mocks, no waiting. Each transition is one line of input and one of expected output, which makes it cheap to cover the awkward cases — a success arriving after a reset, a removal of an id that is not there.
Lazy initialisation
function init(items) {
return { items, selected: null, filter: "" };
}
const [state, dispatch] = useReducer(reducer, initialItems, init);
The third argument runs once with the second as its input. Useful for
expensive setup, and for reusing the same function to handle a
reset action: case "reset": return init(action.items);
dispatch is stable, and that matters
React guarantees the same dispatch function for the life of the
component. So it never needs to be in a dependency array, it never breaks a
memo, and it can go into context without a
useCallback — which is why the state-and-dispatch context
split in the previous chapter works so
cleanly.
Seeing every action while debugging
function withLogging(reducer) {
return (state, action) => {
const next = reducer(state, action);
console.log(action.type, { state, next });
return next;
};
}
const [state, dispatch] = useReducer(withLogging(reducer), initial);
Because a reducer is just a function, wrapping one gives you middleware for free. Remove it before shipping: logging is a side effect, and Strict Mode's double call will print every action twice in development.
Reducer plus context: the poor man's store
function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, []);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>{children}</TasksDispatchContext>
</TasksContext>
);
}
This covers a surprising amount of ground before you need a real library:
centralised logic, a stable dispatch, and components that only read what they
need. What it does not give you is selectors — every consumer of
TasksContext re-renders on every change — and that is the
line where a store starts earning
its dependency.
Keeping it immutable
case "toggle":
return {
...state,
items: state.items.map((i) =>
i.id === action.id ? { ...i, done: !i.done } : i
),
};
The same rules as useState: return a new object, spread at every
level you change. If the nesting makes this painful, either flatten the state
or use Immer's produce, which lets you write mutations and
produces an immutable result. Returning the same state object is also
meaningful: React sees nothing changed and can skip re-rendering.
With TypeScript, type the reducer's parameters and let React infer the rest:
useReducer(reducer, initial), no type arguments. See
TypeScript with React.
Interview answer, one sentence
"A reducer moves state transitions out of the handlers and into one pure
function, so related fields always change together and impossible
combinations stop being reachable. It is also testable without rendering,
and dispatch is stable, which makes it ideal to put in context;
the async work stays in the handler, which dispatches what happened."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series