Lesson 18 of 31
useReducer for Complex State
When a handful of related setState calls stop being manageable, and a single dispatch-and-reducer pattern makes state transitions easier to read, test, and reason about.
useState handles most components fine. Occasionally state grows into
several related pieces that change together, in response to the same
events, and tracking that with separate useState calls starts to blur
together the logic of what happened and how state should react to it.
useReducer separates those two concerns.
The shape
import { useReducer } from "react";
function reducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + action.amount };
case "decrement":
return { count: state.count - action.amount };
case "reset":
return { count: 0 };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "increment", amount: 1 })}>+1</button>
<button onClick={() => dispatch({ type: "decrement", amount: 1 })}>-1</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</div>
);
}dispatch doesn't set state directly — it sends an action, a plain
object describing what happened. The reducer function is the single
place that interprets actions and computes the next state. This is exactly
the same idea as an event listener versus its handler: dispatch says "this
event occurred," and the reducer decides what that means for state.
Why a reducer must be pure
A reducer takes (state, action) and returns a new state — nothing else.
No fetching, no setTimeout, no mutating the state argument. Purity is
what makes this pattern worth the extra structure: given the same state and
the same action, a reducer always produces the same result, which makes it
trivial to test in isolation (just call it directly with sample inputs and
check the output) and easy to reason about without running the whole
component.
// A reducer is just a function — testable with no React involved at all
test("increment adds the given amount", () => {
expect(reducer({ count: 0 }, { type: "increment", amount: 5 })).toEqual({ count: 5 });
});When it earns its complexity
useReducer isn't a strict upgrade over useState — for a single value, or
a couple of independent ones, separate useState calls are simpler and more
direct. It earns its place specifically when:
- Several pieces of state change together in response to the same event.
- The next state genuinely depends on the previous state in a non-trivial way (not just "add one").
- The same kind of update happens from several different places, and you want one reviewable place that defines what each update actually does.
A form with interdependent fields is a common realistic case — selecting a country might need to reset the selected state/province, for instance:
function formReducer(state, action) {
switch (action.type) {
case "setCountry":
return { ...state, country: action.value, region: "" }; // reset region
case "setRegion":
return { ...state, region: action.value };
default:
return state;
}
}Expressing "changing the country also resets the region" as a useState
pair would mean remembering to call setRegion("") at every single call
site that changes the country. In the reducer, that rule lives in exactly
one place and can't be forgotten by a future call site.
Try it yourself
What to remember
- useReducer returns
[state, dispatch]; dispatch sends a plain action object describing what happened, and a pure reducer function computes the next state from it. - A reducer must be pure — no side effects — which is exactly what makes it easy to test and predict in isolation.
- Reach for useReducer when several pieces of state change together, or when the same kind of transition happens from multiple places and deserves one single, reviewable definition.
- For simple, independent values, plain useState calls remain simpler — useReducer is a tool for complexity, not a default.
Check yourself
4 questions · pass 3/4 to unlock Custom Hooks
1.What are the two things you get back from
useReducer(reducer, initialState)?2.What must a reducer function be, and why does that matter?
3.Why might useReducer be preferred over several separate useState calls for something like a multi-step form with several interdependent fields?
4.In
dispatch({ type: "increment", amount: 5 }), what is the object being passed usually called?
4 left to answer