Skip to content
Chapter 24Lesson 4

useReducer when transitions multiply

React's useReducer hook, for when several pieces of component state must change together as one state machine.

So far in this chapter useState has been the answer to every “where does this value live” question. A counter, a toggle, the selected tab, a single form field: one value, one setter, one or two ways it changes. That is the right reach for most state, and it stays the right reach.

This lesson is about the moment it stops being enough. useState does not break. The trouble starts when a component accumulates several state values that must change together to stay correct. When values move in lockstep like that, scattering them across a pile of independent setters is how bugs get in. You already have every piece you need to fix this: immutable updates, the snapshot model, purity, and discriminated unions with exhaustiveness checking. useReducer is the hook that puts all of them to work at once. It is not a new primitive to memorize, but the four things you already know, assembled into one shape.

Here is the idea to carry through the lesson. A reducer is a state machine you write inline, and you reach for it when your useStates start coordinating, not before.

Here is a Client Component that creates a new invoice. It validates the fields, then saves to the server. It is the kind of form you’ll write dozens of times.

new-invoice-form.tsx
'use client';
const NewInvoiceForm = () => {
const [values, setValues] = useState({ client: '', amount: '' });
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSaved, setIsSaved] = useState(false);
const handleSubmit = async () => {
setIsSubmitting(true);
const nextErrors = validate(values);
if (Object.keys(nextErrors).length > 0) {
setErrors(nextErrors);
// 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
};

Follow the bug step by step. The user clicks Submit. setIsSubmitting(true) fires, and the button shows a spinner. Validation fails because a field is empty, so we set the errors and return early to let the user fix them. But that early return skips right past setIsSubmitting(false), so the spinner spins forever. The form is now stuck: the user fixes the field, but the button is still disabled because nothing ever reset isSubmitting.

You could patch this by adding setIsSubmitting(false) before the early return. But notice what the patch is asking of you. Every handler, on every exit path, must remember to leave all five of these values in a consistent state. Nothing stops you from setting isSubmitting to true while isSaved is also true, and nothing stops submitError from holding a message while the form claims it saved successfully. The compiler accepts all of these, even though each one is a bug waiting for the wrong code path to run.

That is the real diagnosis. These five values are not five independent pieces of state. They are one machine wearing five hats. This form can legitimately be in only a handful of situations: editing, submitting, saved, failed. But you’ve encoded those 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 states, 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 rule you can apply quickly: switch to a reducer when three or more useState calls update together. That covers three or more values that move in lockstep, a transition that has to maintain an invariant , or a single value that gets set from many scattered handlers. Below that threshold, useState is correct and a reducer is just ceremony.

The signature: state, dispatch, and a reducer

Section titled “The signature: state, dispatch, and a reducer”

Now that you want the tool, here is its shape. useReducer looks a lot like useState, and that resemblance is deliberate.

const [state, dispatch] = useReducer(reducer, initialState);

There are four pieces, each one a small idea:

  • reducer is a pure function with the shape (state, action) => newState. It takes the current state and a description of something that happened, and returns the next state. You define it outside the component, because it depends on nothing but its two arguments. That also means you can unit-test it without rendering anything, which we’ll return to later.
  • state is the current snapshot. It follows the same snapshot rule you learned for useState’s value: it’s frozen for this render, and dispatching doesn’t change it 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 whatever the reducer returns. Like useState’s setter, dispatch is referentially stable: it’s the same function across every render.
  • a third argument, init, is an optional lazy initializer. We’ll come back to it, so ignore it for now.

The parallel to useState is the whole point, so it is worth naming directly. useState hands you [value, setValue]: one value, one setter dedicated to it. useReducer hands you [state, dispatch]: one state object, and one dispatcher that accepts a named vocabulary of changes. You’re trading “a setter per value” for “one dispatcher and a list of the things that can happen.” When you only have one or two values, the setter-per-value trade is cheaper. When you have a coordinated cluster, the named-vocabulary trade wins, because the vocabulary becomes the single, enforceable definition of how the state is allowed to move.

Two facts carry over from useState, and both matter later in the lesson, so hold on to them now.

First, dispatch is stable. Its identity never changes between renders, which makes it safe to list in effect dependency arrays and safe to pass down to a child as a prop. This is worth pausing on, because in The 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 contract a child codes against is the action vocabulary, not the setter. Passing dispatch down is idiomatic. Passing setIsSubmitting down is a smell.

Second, state follows the snapshot rule. Dispatching an action queues it; the state you’re holding right now does not mutate. The next render sees the next state. If you’ve internalized why setCount(count + 1) twice in a row only adds one, you already understand this.

Before we write the reducer, we write its vocabulary, because the set of actions is the component’s API for change. Everything that can happen to this form is one of a fixed list of events, and that list is a discriminated union.

type Action =
| { type: 'editField'; field: keyof Values; value: string }
| { type: 'submit' }
| { type: 'success' }
| { type: 'error'; message: string }
| { type: 'reset' };

This is the discriminated union you already know, now doing a job. The type field is the discriminant , the literal field TypeScript switches on to narrow the union. Everything else on each member is the payload , the data the action carries beyond its type. An editField action carries which field changed and its new value; an error action carries a message; submit, success, and reset carry nothing because they don’t need to.

The payoff is the same one discriminated unions always give you: TypeScript narrows per branch. Inside a case 'error', the compiler knows action.message exists. Inside a case 'submit', it knows there’s no message to read. You get this with zero casts, which is exactly what makes the reducer body type-safe.

There’s a naming discipline here that 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 say what occurred, and the reducer decides the consequence. Contrast that with an action called 'setIsLoading'. That action has already decided the consequence, which is to flip a boolean, so the reducer isn’t governing anything. 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 is free to decide what 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 one 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', and the reducer maps it to status: 'saved'.
  • 'setSubmitting' is a setter wearing a verb. The event is 'submit'.

Here is 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 reducer: one exhaustive switch, no mutation

Section titled “The reducer: one exhaustive switch, no mutation”

With the vocabulary in place, we can write the function that consumes it. The reducer takes the state and an action and returns the next state, and it does so as one switch over the action’s discriminant. Let’s read it case by case.

const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'editField':
return { ...state, values: { ...state.values, [action.field]: action.value } };
case 'submit':
return { ...state, status: 'submitting', submitError: null };
case 'success':
return { ...state, status: 'saved' };
case 'error':
return { ...state, status: 'idle', submitError: action.message };
case 'reset':
return initialState;
default:
return assertNever(action);
}
};

The signature is (state, action) => State, defined outside the component because it needs nothing else. The types live right here, and this is what useReducer reads to infer the types of state and dispatch. No generics on the hook call.

const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'editField':
return { ...state, values: { ...state.values, [action.field]: action.value } };
case 'submit':
return { ...state, status: 'submitting', submitError: null };
case 'success':
return { ...state, status: 'saved' };
case 'error':
return { ...state, status: 'idle', submitError: action.message };
case 'reset':
return initialState;
default:
return assertNever(action);
}
};

editField spreads ...state first to copy the whole object, then nest-spreads values, then overrides the one field. This is the immutable-update reflex from The useState surface and lazy initialization, one level deeper. The rule that prevents the classic bug is to always spread ...state first, then override. Forget it and you return a partial state, with every other field gone.

const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'editField':
return { ...state, values: { ...state.values, [action.field]: action.value } };
case 'submit':
return { ...state, status: 'submitting', submitError: null };
case 'success':
return { ...state, status: 'saved' };
case 'error':
return { ...state, status: 'idle', submitError: action.message };
case 'reset':
return initialState;
default:
return assertNever(action);
}
};

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. There is no code path where the form is “submitting” but still showing a stale error, because that combination never gets a chance to exist.

const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'editField':
return { ...state, values: { ...state.values, [action.field]: action.value } };
case 'submit':
return { ...state, status: 'submitting', submitError: null };
case 'success':
return { ...state, status: 'saved' };
case 'error':
return { ...state, status: 'idle', submitError: action.message };
case 'reset':
return initialState;
default:
return assertNever(action);
}
};

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 only legal because the discriminant narrowed the union: TypeScript knows an error action carries a message, so no cast is needed.

const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'editField':
return { ...state, values: { ...state.values, [action.field]: action.value } };
case 'submit':
return { ...state, status: 'submitting', submitError: null };
case 'success':
return { ...state, status: 'saved' };
case 'error':
return { ...state, status: 'idle', submitError: action.message };
case 'reset':
return initialState;
default:
return assertNever(action);
}
};

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 and forget its case, and this line fails to compile. The red doesn’t mean the code is broken. It means this is your safety net, catching the missing case at build time rather than in production.

1 / 1

This brings us to the core of the lesson. Look at the state this reducer governs:

type State = {
values: Values;
status: 'idle' | 'submitting' | 'saved';
submitError: string | null;
};

There are no isSubmitting, isSaved, or hasError booleans here. There is one status field that can be exactly one of three strings. That single change is the difference between the form that wedges and a form that can’t.

Count the combinations. Three booleans give you eight of them, true/false cubed, and several are nonsense: submitting and saved, saved and errored, all three true at once. The scattered version let any handler produce any of those eight, and trusted you never to produce the bad five. A single status field has exactly three values, all of them 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 the lesson from Impossible states, unrepresentable, now enforced at runtime by a hook instead of only described by a type.

That is the real argument for reaching past useState. The argument is not that the reducer is less code, because sometimes it’s more. The argument is that the reducer makes the broken states impossible to reach.

Two things come with writing reducers that are worth watching for, and they belong right here.

The first is the bailout, carried over from useState. If your reducer returns the same reference it received, with return state, React compares with Object.is, sees no change, and skips the re-render entirely. That’s occasionally what you want, when you deliberately ignore an action that doesn’t apply right now. Far more often it’s a bug: you meant to compute a new state and accidentally 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.

We’ve built the vocabulary and the reducer. Now watch the component go from five scattered setters to one dispatcher. This before and after is the payoff, so here are both side by side.

const [values, setValues] = useState({ client: '', amount: '' });
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSaved, setIsSaved] = useState(false);
const handleSubmit = async () => {
setIsSubmitting(true);
setSubmitError(null);
const result = await saveInvoice(values);
setIsSubmitting(false);
setIsSaved(true);
};

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.

Reading state in the JSX is the mirror image of writing it. The button’s disabled is driven straight off the machine: state.status === 'submitting'. There’s no isSubmitting boolean to keep in sync with anything, because the status is the truth. The error renders with state.submitError != null && <FieldError ... />. Because submitError is string | null, the explicit != null check is the convention-correct guard: it renders the error for any non-null string, including the empty string, and it never accidentally renders a stray 0 or false the way a bare && on a non-boolean can.

The inputs are controlled exactly as you learned in The four homes for state: value reads from state.values, and onChange calls a typed handler that dispatches editField. Notice that the handler shape didn’t change at all. It’s still a typed callback that takes the field and the new value. The only thing that changed is where the value goes when it gets there: into a dispatch instead of a setState. The controlled-input contract is identical, and you swapped only the storage underneath it.

Here 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 wrapper 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 in a direct parent-child line is a job for context, which is recognition-only here; we’ll get to it in the chapter on effects and context.)

Async belongs in the handler, never the reducer

Section titled “Async belongs in the handler, never the reducer”

Here is the single most common mistake people make when they first reach for a 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’s only job is to map 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.

const handleSubmit = async () => {
dispatch({ type: 'submit' });
const result = await saveInvoice(state.values);
if (result.ok) {
dispatch({ type: 'success' });
} else {
dispatch({ type: 'error', message: result.error });
}
};

There are three dispatches around one await, and they 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. When it resolves, dispatch success or error depending on the result. The reducer never touches the Promise. It only ever sees the three plain actions and maps each to a state.

The saveInvoice call returns a { ok } shape, either { ok: true } or { ok: false; error: string }. That’s the Result type convention from earlier in the course, and it’s deliberate here: it keeps the success and failure branch a clean discriminated check instead of wrapping the teaching snippet in try/catch. In a real form you’d handle thrown errors too, but the shape of the lesson is the part that matters: 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 = await fetch('/api/invoices');
return { ...state, status: 'saved', data };
}

This breaks the reducer in three separate ways. A reducer must be synchronous: it returns the next state now, not a Promise of it. It must also be pure, and a fetch is a side effect , which reducers are not allowed to have. And because Strict Mode invokes the reducer twice in development, an await fetch here would fire the request twice. The fix is never to make the reducer async. It’s 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 SCREEN Button disabled, spinner shown — re-rendered already
dispatch('submit') runs the reducer synchronously: idle → submitting. React re-renders right now, so the button is disabled and the spinner is on screen. The save hasn't happened yet.
idle
dispatch('submit')
submitting
dispatch('success')
saved
dispatch('submit')
await saveInvoice(…)
resolves
ON SCREEN Still 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 and the spinner keeps spinning. This is the gap the scattered version never reset out of.
idle
dispatch('submit')
submitting
dispatch('success')
saved
dispatch('submit')
await saveInvoice(…)
resolves
ON SCREEN Success UI shown

The Promise resolved ok, so the handler dispatches success: submitting → saved. React re-renders into the success UI.

idle + error
dispatch('submit')
submitting
dispatch('error')
saved
dispatch('submit')
await saveInvoice(…)
resolves
ON SCREEN Button re-enabled, error message shown

The Promise resolved with a failure, so the handler dispatches error: submitting → idle, carrying the message in submitError. The button re-enables and the error shows. “Failed” is idle plus an error, not a fourth status.

Two things to recognize for later, neither of which you need now. When this submit is a real form, React packages exactly this start-await-resolve shape for you with useActionState and Server Actions. That’s the forms unit later in the course, and it’s the production pattern; the handler here is the teaching version of it. Separately, if you ever need to mark a dispatch as non-urgent so React can interrupt it for higher-priority updates, you wrap it in useTransition, which is in the next chapter.

Remember the third argument we set aside. useReducer takes an optional init function, and it works just like the lazy initializer for useState.

const init = (invoice: Invoice): State => ({
values: { client: invoice.client, amount: String(invoice.amount) },
status: 'idle',
submitError: null,
});
const [state, dispatch] = useReducer(reducer, invoice, init);

The middle argument is the input, and init runs once on mount to turn it into the reducer’s State shape. Here an incoming invoice record, say a server row the user is editing, gets mapped into 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 and lazy initialization: when computing the initial state takes real work, such as parsing a saved draft from storage, deriving structure from a prop, or mapping a server record into a form shape. For a plain literal, skip it and pass the object directly.

There’s a small symmetry worth naming. useState’s lazy form is a thunk, useState(() => compute()), a function taking no arguments. useReducer’s form passes the input into init, as useReducer(reducer, invoice, init), so init is a named, reusable, separately testable function rather than an inline closure. It’s the same idea in a slightly more composable shape.

The same caveat from the lazy-useState lesson 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. That’s the frozen-prop behavior from earlier in the chapter. When the form genuinely needs to reset to a new record, you don’t re-initialize. You remount it with a key reset, giving the component a fresh start. Re-initializing on prop change is not a thing reducers do.

You now know how a reducer works. The harder skill is knowing when it’s 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?

Notice the “No, independent” branch lands on grouped useState, a single useState({ ... }) holding the related values, updated with spreads. That’s the middle ground between scattered separate setters and a full reducer: when several values belong together but their changes are simple and local, one state object is plenty. (This is the separate-versus-grouped decision that The useState surface and lazy initialization pointed forward to, and here’s where it lands.)

As a checklist you can carry, 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.

The inverse is that useState wins when the value is a counter, a toggle, or a single field with one or two ways it changes. Reach for the reducer when the ceremony pays for itself.

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, and that’s the clearest cue there is to consolidate.

Now write a transition yourself and watch the safety net catch you. Below is a small download button driven by a reducer, a four-state machine: idle → loading → done | error. The 'start' and 'success' cases are written, the Action union is complete, and assertNever is in place at the default. But the 'error' case is missing, which means the file won’t compile and the failure path doesn’t work.

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.

Preview
    Reference solution
    case 'error':
    return { ...state, status: 'error', error: action.message };

    Drop it above the default. It spreads ...state first so the rest of the state survives, flips status to 'error', and copies action.message into error. TypeScript knows action.message exists here because the discriminant narrowed the union. With every action type now handled, action at the default narrows to never, so assertNever(action) type-checks and the compile error is gone.

    When you add the case, watch the assertNever error disappear. That’s the exhaustiveness guard doing its job: it refused to compile while a possible action had no home, and it went quiet the moment you gave the last one a destination. That guarantee, that every action is handled, enforced by the compiler, is a large part of why a reducer with a discriminated-union action is worth the ceremony.

    State machines, libraries, and the names you’ll meet

    Section titled “State machines, libraries, and the names you’ll meet”

    A reducer is a small, library-free version of a pattern with a lot of names attached to it. You don’t need any of these to build with useReducer, but you’ll hear them, and knowing what they refer to keeps you oriented.

    It's a state machine

    idle → loading → done | error is a four-state machine, and a reducer is the inline, dependency-free way to model one. When transitions get guarded or nested and the machine is genuinely complex, the dedicated library is XState. It’s overkill for most components and the right call when the machine truly warrants it.

    Redux vocabulary transfers

    Actions, reducers, and dispatch make useReducer essentially “single-component Redux.” This course doesn’t teach Redux, but the mental model you just built carries directly 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, because 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 mitigated by splitting state and dispatch into separate contexts, which the chapter on effects and context covers.

    For the reference you’ll actually open, the React docs entry on useReducer is the canonical one, collected with a few other resources at the end of this lesson.

    Here is the shape to leave with: state shape is a design decision. When several values move together and some of their combinations are illegal, stop scattering useState and model the transitions as a reducer: a pure function over (state, action) whose action is a discriminated union, whose body is an exhaustive switch, and whose async lives in the caller. That single move turns a form that can wedge into a machine that can’t.