You start with one useState. Then another. Then a third. Before you know it, a single event handler calls four setters in sequence and you've lost track of which combination of state values is valid. Sound familiar?
👉 Try it in practice: Calculator with reducer
React gives us two built-in tools for component state: useState for independent values, and useReducer for grouped state transitions. The problem is that nobody tells you where the line is. You only notice you crossed it when the code starts hurting.
Here's how to recognize that moment — and what to do about it.
The smell: state that moves together
The clearest signal is when a single user action needs to update multiple state values at once. If you see a pattern like this:
const [isOpen, setIsOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
And then an event handler that touches all of them:
function handleSelect(item: string) {
setSelectedItem(item);
setIsOpen(false);
setError(null);
setIsSubmitting(false);
}
You're describing a state transition — moving from one valid state to another — but you're encoding it as a sequence of independent mutations. The more setters you call in one handler, the more likely you are to forget one, or call them in the wrong order, or leave the component in an impossible state like isSubmitting: true and error: "timeout" at the same time.
A second signal: derived state scattered across the component. If you find yourself writing if (isOpen && selectedItem && !error) in five different places, those three booleans aren't independent. They describe a single concept — the UI mode the component is in — and you're reconstructing it from pieces over and over.
A third signal: reset logic that looks like a checklist. When closing a modal or resetting a form, if you need six setX(initialX) calls in a row, the state belongs together.
The fix: describe state transitions, not state mutations
useReducer lets you define the legal transitions explicitly:
type State =
| { status: "idle" }
| { status: "selecting"; items: Item[] }
| { status: "selected"; item: Item }
| { status: "submitting"; item: Item }
| { status: "error"; item: Item; message: string };
type Action =
| { type: "OPEN_LIST"; items: Item[] }
| { type: "SELECT_ITEM"; item: Item }
| { type: "SUBMIT" }
| { type: "SUBMIT_ERROR"; message: string }
| { type: "RESET" };
Instead of five separate booleans and nullables, you have a single state value that is always in exactly one valid mode. The reducer maps actions to states:
function reducer(state: State, action: Action): State {
switch (action.type) {
case "OPEN_LIST":
return { status: "selecting", items: action.items };
case "SELECT_ITEM":
if (state.status !== "selecting") return state;
return { status: "selected", item: action.item };
case "SUBMIT":
if (state.status !== "selected") return state;
return { status: "submitting", item: state.item };
case "SUBMIT_ERROR":
if (state.status !== "submitting") return state;
return { status: "error", item: state.item, message: action.message };
case "RESET":
return { status: "idle" };
default:
return state;
}
}
Notice what disappeared: the impossible states. There is no branch where isSubmitting is true and selectedItem is null, because the type system won't allow it. The reducer rejects transitions that don't make sense — if the user somehow triggers SUBMIT while the component is idle, it returns the current state unchanged.
The component becomes a thin shell that dispatches intent:
const [state, dispatch] = useReducer(reducer, { status: "idle" });
return (
<div>
{state.status === "selecting" && (
<ItemList
items={state.items}
onSelect={(item) => dispatch({ type: "SELECT_ITEM", item })}
/>
)}
{state.status === "selected" && (
<button onClick={() => dispatch({ type: "SUBMIT" })}>
Confirm {state.item.name}
</button>
)}
{state.status === "submitting" && <Spinner />}
{state.status === "error" && <ErrorMessage message={state.message} />}
</div>
);
No more if (isOpen && selectedItem && !isSubmitting && !error). The component renders based on a single discriminant — state.status — and TypeScript narrows the available properties automatically.
When to switch (and when not to)
Not every component needs useReducer. Here is a practical heuristic:
| Stick with useState | Consider useReducer |
|---|---|
| 1-2 independent values | 3+ values that change as a unit |
| Each setter is called in isolation | One handler calls multiple setters |
| Reset is one or two lines | Reset requires a checklist of initial values |
| State combinations are always valid | Some combinations are impossible or bug-prone |
| The component is 50 lines | The component is 150+ lines and growing |
If you find yourself writing a function called resetForm that exists solely to call six setters in sequence, you've already built a reducer — just one spread across your component body.
Don't use useReducer for everything. A single boolean toggle doesn't need a reducer. Two independent counters in the same component don't need to be merged. The reducer shines when state values are coupled — when changing one implies changing others, and the set of legal combinations is smaller than the Cartesian product of all values.
The testing advantage
The most underrated benefit of useReducer is that the reducer is a pure function. You can test every state transition without rendering a component:
test("selecting an item from idle should do nothing", () => {
const state = { status: "idle" as const };
const next = reducer(state, {
type: "SELECT_ITEM",
item: { id: 1, name: "Foo" },
});
expect(next).toBe(state); // no transition allowed
});
test("submitting a selected item moves to submitting state", () => {
const state = { status: "selected" as const, item: { id: 1, name: "Foo" } };
const next = reducer(state, { type: "SUBMIT" });
expect(next.status).toBe("submitting");
expect(next.item.name).toBe("Foo");
});
No mocks, no act(), no render. Just a function that takes state and returns state. When the core logic lives in a reducer, your component tests become thin integration checks, and the business logic is tested fast, in isolation, with full confidence.
👉 Try it in practice: Calculator with reducer
Top comments (0)