The shape
dispatch is stable forever — it never changes identity, so it can be passed to a memoised child or left out of a dependency array without a thought.
function reducer(state, action) {
switch (action.type) {
case "add": return { ...state, items: [...state.items, action.door] };
case "remove": return { ...state, items: state.items.filter((d) => d.id !== action.id) };
case "reset": return INITIAL;
default: return state;
}
}
const [state, dispatch] = React.useReducer(reducer, INITIAL);
<button onClick={() => dispatch({ type: "add", door })}>Add</button>
Why it beats several useState calls
- The next state depends on the previous one in more than a trivial way.
- Several fields change together — a successful load sets the data, clears the error and changes the status.
- The same transition happens from several places — a keyboard shortcut, a button and a drop target all mean "add".
- You want to read the rules without chasing them through eight handlers.
// four setters, and the rules live in every handler that touches them
const [items, setItems] = useState([]);
const [express, setExpress] = useState(false);
const [error, setError] = useState(null);
const [status, setStatus] = useState("idle");
// one dispatch, and the rules live in one place
dispatch({ type: "add", door });
A reducer must be pure
Because it is pure, it can be called in a test with no React at all: give it a state and an action, and assert on what comes back. That is the biggest practical reason to use one.
function reducer(state, action) {
state.items.push(action.door); // NO: mutating the state
fetch("/api/save", …); // NO: side effects belong in handlers or effects
return { ...state, at: Date.now() }; // NO: not deterministic
// yes: a new object, computed only from state and action
return { ...state, items: [...state.items, action.door] };
}
Naming actions
When actions name events, the reducer becomes a readable list of the rules of your feature — and a new rule is a new case, not a change in five components.
// describes the mechanism — you end up with a setter with extra steps
dispatch({ type: "setItems", items: [...items, door] });
// describes what happened — the reducer decides what that means
dispatch({ type: "added", door });
dispatch({ type: "removed", id });
dispatch({ type: "express_toggled" });
Rules live in the reducer
case "added": {
if (state.items.some((d) => d.id === action.door.id)) return state; // no duplicates
if (state.items.length >= 5) return { ...state, error: "Five is the maximum" };
return { ...state, error: null, items: [...state.items, action.door] };
}
Returning state unchanged
Returning the same object tells React nothing happened, and it skips the re-render. That is how a reducer rejects an action cleanly.
Lazy initialisation
function init(doors) {
return { items: doors, express: false, error: null };
}
const [state, dispatch] = React.useReducer(reducer, doors, init);
// and reset becomes trivial
case "reset": return init(action.doors);
Reducer or a library
- One feature, one component tree:
useReduceris plenty. - Shared across the app: a reducer plus context, or a small store like Zustand.
- Server data: neither — that is a cache, and belongs in TanStack Query.
- Redux today is mostly Redux Toolkit, and is the same idea with devtools, middleware and a single store.