React's useReducer hook, for when several pieces of state must change together as one machine.
useState has answered every “where does this value live” question so far: a counter, a toggle, the selected tab, one field with one setter. For most state that is the right reach, and it stays the right reach.
The trouble starts when a component accumulates several state values that must change together to stay correct. When values move in lockstep, scattering them across independent setters is how bugs get in. useReducer is the fix, and it is not a new primitive: it assembles four things you already know, immutable updates, the snapshot model, purity, and discriminated unions with exhaustiveness checking, into one shape.
A reducer is a state machine you write inline. You reach for it when your useStates start coordinating, not before.
// Bails out before the spinner is ever turned back off.
return;
}
const result = await saveInvoice(values);
setIsSubmitting(false);
setIsSaved(true);
};
// …the fields, the submit button, the error display
};
The user clicks Submit. setIsSubmitting(true) fires and the button shows a spinner. A field is empty, so validation fails: we set the errors and return early to let the user fix it. But that early return skips setIsSubmitting(false), so the spinner spins forever. The user fixes the field, but the button stays disabled because nothing reset isSubmitting.
You could add setIsSubmitting(false) before the early return. But notice what that patch demands: every handler, on every exit path, must leave all five values consistent. Nothing stops isSubmitting from being true while isSaved is also true, or submitError from holding a message while the form claims it saved. The compiler accepts every one of these bugs, waiting for the wrong code path to run.
That is the diagnosis. These five values are not independent state. They are one machine wearing five hats. The form can really only be editing, submitting, saved, or failed, yet you’ve spread those four situations across five booleans and strings that any handler can set in any combination. The legal states are a tiny island in a sea of nonsense, and nothing keeps you on the island.
When several useStates must change together to stay correct, you don’t have five pieces of state. You have one, and you want one place that owns every legal move from one situation to the next. That place is a reducer, reached through useReducer.
Here is the threshold as a quick rule: switch to a reducer when three or more useState calls update together. That covers three or more values moving in lockstep, a transition that must maintain an invariant , or a single value set from many scattered handlers. Below that, useState is correct and a reducer is just ceremony.
reducer is a pure function of the shape (state, action) => newState. It takes the current state and a description of what happened, and returns the next state. You define it outside the component, since it depends on nothing but its two arguments, which also means you can unit-test it without rendering.
state is the current snapshot. It follows the same snapshot rule as useState’s value: frozen for this render, unchanged by dispatching mid-render.
dispatch(action) is the function you call to make a change. It queues the action; React then calls your reducer with the current state plus that action and re-renders with the result. Like useState’s setter, dispatch is referentially stable: the same function on every render.
init, an optional third argument, lazily initializes the state. Ignore it for now.
The parallel is the point. useState hands you [value, setValue]: one value, one dedicated setter. useReducer hands you [state, dispatch]: one state object, and one dispatcher that accepts a named vocabulary of changes. With one or two values, a setter per value is cheaper. With a coordinated cluster, the vocabulary wins, because it becomes the single, enforceable definition of how the state is allowed to move.
Two facts about dispatch and state matter later, so hold on to them.
First, dispatch is stable. Its identity never changes, so it’s safe to list in effect dependency arrays and to pass to a child as a prop. That last part is worth pausing on, because in Four homes for state the advice was not to prop-drill raw setState setters. dispatch is the sanctioned exception: its identity is stable, and the child codes against the action vocabulary, not a setter. Passing dispatch down is idiomatic; passing setIsSubmitting down is a smell.
Second, state follows the snapshot rule. Dispatching queues the action; the state you hold now does not mutate, and the next render sees the next state. If you know why setCount(count + 1) twice only adds one, you already understand this.
The set of actions is the component’s API for change, so we write it before the reducer. Everything that can happen to this form is one of a fixed list of events: a discriminated union.
This is the discriminated union you already know, now doing a job. The type field is the discriminant ; everything else on a member is its payload . An editField action carries which field changed and its new value; an error action carries a message; submit, success, and reset carry nothing.
The payoff is the one discriminated unions always give you: TypeScript narrows per branch. Inside case 'error', the compiler knows action.message exists; inside case 'submit', it knows there’s no message to read. No casts, which is what makes the reducer body type-safe.
One naming discipline separates a real reducer from useState in disguise. Action types name events that happened or commands you’re issuing, not setters.'submit', 'editField', and 'success' describe intent: they report what occurred, and the reducer decides the consequence. An action called 'setIsLoading' has already decided the consequence, so the reducer governs nothing; it’s just a switchboard. A reducer whose actions are setter-shaped is useState with extra steps and none of the benefit. The whole value of the pattern is that the caller describes what happened and the reducer is the single authority on what that means for the state.
The sorting exercise below makes that distinction concrete.
Each of these is a candidate action `type`. Sort them by whether the name describes an event the reducer interprets, or a consequence already decided for it.
Drag each item into the bucket it belongs to, then press Check.
Intent (good action)Names what happened; the reducer decides the consequence
Setter in disguise (smell)The consequence is already baked into the name
'submit'
'editField'
'paymentReceived'
'cancelOrder'
'retry'
'setIsLoading'
'setError'
'updateStatusToSaved'
'setSubmitting'
Answer key: why each lands where it does
Intent (good action): the name reports an event, and the reducer decides the state it produces.
'submit' describes what the user did; the reducer decides it means status: 'submitting'.
'editField' says a field changed; the reducer decides where the new value goes.
'paymentReceived' is a thing that happened in the world; the reducer maps it to a state.
'cancelOrder' is a command the user issued; the reducer decides the resulting status.
'retry' says the user asked to try again; the reducer decides what resetting and resubmitting looks like.
Setter in disguise (smell): the name has already picked the consequence, so the reducer governs nothing. Rename each to the event and let the reducer decide the rest.
'setIsLoading' is just setIsLoading renamed. Dispatch 'submit' and let the reducer set the loading status.
'setError' shoves the message straight into state. Dispatch the event, 'error', instead.
'updateStatusToSaved' names the exact field write. The event is 'success', which the reducer maps to status: 'saved'.
'setSubmitting' is a setter wearing a verb. The event is 'submit'.
The tell: if you can rewrite the action as setX(...) with no loss, it’s a setter in disguise. Real actions survive no such rewrite, because there’s no setter for “the payment was received.”
The signature is (state, action) => State, defined outside the component because it needs nothing else.
The types live here, and useReducer reads them to infer state and dispatch.
No generics on the hook call.
editField spreads ...state to copy the object, nest-spreads values, then overrides the one field.
This is the immutable-update reflex from The useState surface, one level deeper.
The rule that prevents the classic bug: always spread ...state first, then override. Forget it and you return a partial state, every other field gone.
submit shows the payoff in one line: it sets status: 'submitting'and clears submitError in the same transition.
The two can’t drift apart, because one move owns both. No code path leaves the form “submitting” while a stale error shows, because that combination never exists.
error returns the machine to idle and stores the message in submitError. “Failed” isn’t a fourth status; it’s idle plus an error to show, so the form is editable again.
Reading action.message is legal because the discriminant narrowed the union: TypeScript knows an error action carries a message, so no cast is needed.
default: assertNever(action) is the exhaustiveness guard from Exhaustiveness, enforced.
If every action type is handled, action narrows to never here and assertNever type-checks. Add a new action, forget its case, and this line fails to compile.
The red isn’t a broken state. It’s your safety net, catching the missing case at build time instead of in production.
1 / 1
This is the core of the lesson. Look at the state this reducer governs:
type State = {
values:Values;
status:'idle'|'submitting'|'saved';
submitError:string|null;
};
No isSubmitting, isSaved, or hasError booleans. One status field, exactly one of three strings. That single change separates the form that wedges from the form that can’t.
Count the combinations. Three booleans give eight, true/false cubed, and several are nonsense: submitting and saved, saved and errored, all three at once. The scattered version let any handler produce any of the eight. A single status field has three values, all legal. The illegal states aren’t guarded against; they’re unrepresentable. You cannot construct “submitting and saved” because no value of status means both. This is Impossible states, unrepresentable, now enforced at runtime by a hook instead of only described by a type.
That is the argument for reaching past useState. Not that the reducer is less code, because sometimes it’s more, but that it makes the broken states impossible to reach.
Two things come with writing reducers, and they belong right here.
The first is the bailout, carried over from useState. Return the same reference you received, with return state, and React compares with Object.is, sees no change, and skips the re-render. That’s occasionally what you want, when you deliberately ignore an action that doesn’t apply. Far more often it’s a bug: you meant to compute a new state and returned the old one, so the screen won’t update. Prefer the assertNever default over a return state default, so a forgotten case is a compile error instead of a silent no-op.
With the vocabulary and the reducer in place, the component goes from five scattered setters to one dispatcher. Here is the before and after, side by side.
Five setters, and every handler is on the honor system to keep them consistent. Nothing stops isSubmitting from staying true while isSaved is also true.
One dispatcher, and the reducer owns every transition. The handlers shrink to a single dispatch, and the illegal combinations are gone: status is one of three strings, never a tangle of booleans.
Reading state in the JSX mirrors writing it. The button’s disabled comes straight off the machine: state.status === 'submitting', with no isSubmitting boolean to keep in sync, because the status is the truth. The error renders with state.submitError != null && <FieldError ... />. Since submitError is string | null, the explicit != null check is the correct guard: it shows the error for any non-null string, including the empty string, and never accidentally renders a stray 0 or false the way a bare && on a non-boolean can.
The inputs stay controlled exactly as in Four homes for state: value reads from state.values, and onChange calls a typed handler. The handler shape is unchanged, still a typed callback taking the field and the new value; only the destination moved, into a dispatch instead of a setState.
The dispatch-down convenience falls out of the stability guarantee from earlier. If one of these inputs were its own child component, you’d pass dispatch straight down as a prop, with no useCallback and no worry about a changing identity, because dispatch never changes. The child dispatches { type: 'editField', ... } and the parent’s reducer handles it. (Sharing one reducer across components that aren’t parent and child is a job for context, covered in the chapter on effects and context.)
The most common mistake with a new reducer, and the rule that prevents it: the reducer is pure, so async cannot live inside it. No fetch, no await, no timers. The reducer maps a current state and an action to the next state, synchronously. The async work, the actual save, lives in the handler that calls dispatch.
So how do you model “save to the server, then succeed or fail”? You bracket the await with dispatches.
Three dispatches around one await, read as a sequence. Dispatch submit to move the machine into submitting, which disables the button and shows a spinner immediately, before anything is saved. Then await the save, and on resolution dispatch success or error. The reducer never touches the Promise; it sees only the three plain actions and maps each to a state.
saveInvoice returns a { ok } shape, either { ok: true } or { ok: false; error: string }. That is the Result type convention from earlier, and it keeps the success and failure branches a clean discriminated check instead of a try/catch around the snippet. A real form would also handle thrown errors, but the shape is the lesson: start, await, then resolve to one of two actions.
Here is the anti-pattern, so you recognize it when you’re tempted to reach for it:
case'submit': {
const data =awaitfetch('/api/invoices');
return { ...state, status: 'saved', data };
}
This breaks the reducer in three ways. A reducer must be synchronous: it returns the next state now, not a Promise of it. It must be pure, and a fetch is a side effect . And because Strict Mode runs the reducer twice in development, an await fetch here would fire the request twice. The fix is always to move the async work up into the handler and let the reducer map the resulting actions. The reducer answers “given this action, what’s the next state”; it never answers “go do some work.”
The sequence diagram below makes the timing concrete. Scrub through it and watch when each render happens relative to the await.
idle
dispatch('submit')
→
submitting
dispatch('success')
→
saved
dispatch('submit')
await saveInvoice(…)
resolves
ON SCREENButton disabled, spinner shown — re-rendered already
dispatch('submit') runs the reducer synchronously: idle → submitting. React re-renders right now, before the save has happened.
idle
dispatch('submit')
→
submitting
dispatch('success')
→
saved
dispatch('submit')
await saveInvoice(…)
resolves
ON SCREENStill disabled, spinner still spinning — no re-render here
The handler is parked on the Promise from saveInvoice(…). No dispatch fires, so no re-render happens. This is the gap the scattered version never reset out of.
idle
dispatch('submit')
→
submitting
dispatch('success')
→
saved
dispatch('submit')
await saveInvoice(…)
resolves
ON SCREENSuccess UI shown
The Promise resolved ok, so the handler dispatches success: submitting → saved.
idle + error
dispatch('submit')
→
submitting
dispatch('error')
→
saved
dispatch('submit')
await saveInvoice(…)
resolves
ON SCREENButton re-enabled, error message shown
The Promise failed, so the handler dispatches error: submitting → idle, carrying the message in submitError. “Failed” is idle plus an error, not a fourth status.
When this submit becomes a real form, React packages exactly this start-await-resolve shape for you with useActionState and Server Actions, the production pattern you’ll meet in the forms unit.
The middle argument is the input, and init runs once on mount to turn it into the reducer’s State. Here an incoming invoice record, say a server row the user is editing, becomes the form’s state: copy the fields across, start at idle, no error yet.
Reach for init under the same threshold as the lazy useState form from The useState surface: when computing the initial state takes real work, such as parsing a saved draft, deriving structure from a prop, or mapping a server record into a form shape. For a plain literal, pass the object directly.
The two forms differ in one way. useState’s lazy form is a thunk, useState(() => compute()); useReducer passes the input into init, so init is a named, reusable, separately testable function rather than an inline closure.
The same caveat applies, because it’s the same mechanism: init runs at mount only. If the invoice prop later changes, when the user picks a different record to edit, init does not re-run and the form keeps the values it started with. To reset the form to a new record, you don’t re-initialize; you remount it with a key reset, giving the component a fresh start.
The harder skill is knowing when a reducer is worth reaching for, and the answer is less often than you’d think. A reducer is a threshold tool, not a default. Walk the decision below in the order an experienced engineer would ask it.
Which state tool does this component need?
One value, one or two transitions. A reducer here is pure ceremony, so reach for useState and move on.
Collapse the booleans into one discriminated status field. The reducer makes the illegal states unrepresentable, not just discouraged: you can’t construct “submitting and saved” because no value of status means both.
Name the transitions and centralize the logic in one pure, testable function instead of scattering setters across handlers. The action vocabulary becomes the single definition of how the state may move.
A single useState({ ... }) object updated with spreads is enough. It’s the middle ground between scattered setters and a full reducer. Revisit if the transitions multiply.
The “No, independent” branch lands on grouped useState: a single useState({ ... }) holding the related values, updated with spreads. When values belong together but their changes are simple and local, one state object is plenty.
useReducer earns its weight when:
several values move together and must stay consistent;
some combinations of them are illegal and you want them unrepresentable;
the update logic is complex enough to deserve a name;
you want to unit-test the transitions without rendering React.
useState wins when the value is a counter, a toggle, or a single field with one or two ways it changes.
One signal outweighs all of these. If you find yourself writing the tenth, eleventh, or twelfth useState in a single component, pause: you don’t have twelve pieces of state, you have a machine you haven’t modeled yet. That’s the clearest cue to consolidate.
This download button is a four-state machine: idle → loading → done | error. The 'start' and 'success' cases are written, the Action union is complete, and assertNever guards the default. The 'error' case is missing, so a failed download has nowhere to land and the file won’t compile.
This download button is a four-state machine: idle → loading → done | error. The 'start' and 'success' cases are written; the Action union is complete; assertNever guards the default. But the 'error' case is missing — so a failed download has nowhere to land. Add the 'error' case so it sets status to 'error' and stores action.message in error. Spread ...state first. The assertNever line stops complaining the moment every action is handled.
Drop it above the default. The spread keeps the rest of the state, status flips to 'error', and action.message lands in error — TypeScript knows message exists here because the discriminant narrowed the union. With every type handled, action at the default narrows to never, so assertNever(action) type-checks and the error is gone.
That compile error was the exhaustiveness guard doing its job: it refused to build while an action had no home, and went quiet once you gave the last one a destination. Every action handled, enforced by the compiler is most of why a discriminated-union reducer earns its ceremony.
State machines, libraries, and the names you’ll meet
A reducer is a small, library-free version of a pattern that goes by many names. You need none of them to use useReducer, but you’ll hear them, and knowing what they point at keeps you oriented.
It's a state machine
idle → loading → done | error is a four-state machine, and a reducer models one inline with no dependencies. Once transitions get guarded or nested and the machine turns genuinely complex, the dedicated library is XState — overkill for most components, the right call when the machine warrants it.
Redux vocabulary transfers
Actions, reducers, and dispatch make useReducer essentially “single-component Redux.” The course doesn’t teach Redux, but this mental model carries to any reducer-based store you meet.
Immer for deep nesting
When spreads pile up on deeply nested state, Immer’s produce lets you write mutating-style code that yields an immutable result. The course default stays explicit spreads: shallow updates dominate, and the spreads keep the intent visible.
Sharing a reducer across components
Done through context, since dispatch is stable and passes cleanly. Context carries a re-render cost, usually eased by splitting state and dispatch into separate contexts — covered in the chapter on effects and context.
State shape is a design decision: when several values move together and some combinations are illegal, model the transitions as a reducer instead of scattering useState.