Structure complex state with a pure reducer that centralizes transitions and rules out impossible combinations.
Open this lesson in KodokonAs soon as a component orchestrates several state values that evolve together - a request status, some data, an error - multiplying useState calls scatters the transition logic across every event handler. You know the symptom: three setters to call in the right order, and sooner or later an inconsistent state, like a status of success sitting next to a non-null error. useReducer flips the responsibility: the state becomes a single object, and every change goes through an action interpreted by one central function, the reducer.
function requestReducer(state, action) {
switch (action.type) {
case "start":
return { status: "loading", data: null, error: null };
case "resolve":
return {
status: "success",
data: action.data,
error: null,
};
case "reject":
return {
status: "error",
data: null,
error: action.error,
};
default:
throw new Error("Unknown action: " + action.type);
}
}The reducer is a pure function: it receives the current state and an action, then returns the next state, with no side effects. The benefits are immediate: transitions are explicit and exhaustive, impossible states disappear at the source (each case rebuilds a consistent object), and the logic reads in a single place instead of being scattered around. A useful warning sign: if you find yourself copy-pasting the same sequence of setState calls in two places, you are probably missing a reducer.
function UserProfile() {
const [state, dispatch] = useReducer(requestReducer, {
status: "idle",
data: null,
error: null,
});
async function load(id) {
dispatch({ type: "start" });
try {
const res = await fetch("/api/users/" + id);
const data = await res.json();
dispatch({ type: "resolve", data });
} catch (error) {
dispatch({ type: "reject", error });
}
}
return <button onClick={() => load(1)}>Load</button>;
}Choosing between useState and useReducer is not a matter of taste. Keep useState for simple, independent values; switch to useReducer when the next state depends on the previous one or when several fields change together. Two extra advantages: the third argument provides lazy initialization (useReducer(reducer, arg, init) only runs init on the first render), and React guarantees that dispatch keeps the same identity from one render to the next - you can pass it down to children without useCallback.