useReducer is useState with intent attached. Instead of setX(newValue) calls scattered across the component, you dispatch named actions ({ type: 'INCREMENT' }) and a single reducer function decides how state evolves.
When to switch from useState
Three signs your component has outgrown useState:
Multiple state values that update together in patterns (e.g. 'when status flips to error, also clear data and set retry count').
Setters scattered across a dozen handlers, each doing partial updates that should be the same logical move.
State transitions you want to name — 'submit_started', 'submit_succeeded', 'submit_failed' — instead of describe with setter calls.
The shape
const [state, dispatch] = useReducer(reducer, initialState). The reducer is a pure function: (state, action) => nextState. It must return a new state object — mutating in place is the same bug as with useState.
Discriminated-union actions
Combine useReducer with Track 2 lesson 5's discriminated unions: each action type carries its own typed payload. The reducer's switch statement narrows the action type, and TypeScript guarantees you handle every case.
When NOT to reach for useReducer
If your state is a single number, a single string, a single boolean, or two unrelated values — useState. useReducer wins when state has verbs (actions that change it in named ways), not just nouns.
The reducer should be pure. Given the same state + action, it always returns the same next state. No fetch calls, no console.log with timestamps, no Math.random(). Side effects live in event handlers (which then dispatch) or effects — never inside the reducer.
Code
Form submission state machine with useReducer + discriminated union·tsx
import { useReducer } from "react";
type State =
| { status: "idle" }
| { status: "submitting"; draft: { title: string } }
| { status: "succeeded"; result: { id: string } }
| { status: "failed"; error: string; draft: { title: string } };
type Action =
| { type: "submit_started"; draft: { title: string } }
| { type: "submit_succeeded"; result: { id: string } }
| { type: "submit_failed"; error: string }
| { type: "reset" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "submit_started":
return { status: "submitting", draft: action.draft };
case "submit_succeeded":
return { status: "succeeded", result: action.result };
case "submit_failed":
// Reuse the draft from the submitting state if we have one.
if (state.status !== "submitting") return state;
return { status: "failed", error: action.error, draft: state.draft };
case "reset":
return { status: "idle" };
default: {
// Exhaustiveness check — fails compile if a new action type is added
// and not handled here.
const _exhaustive: never = action;
return state;
}
}
}
export function NewPostForm() {
const [state, dispatch] = useReducer(reducer, { status: "idle" });
// ... handlers call dispatch({ type: 'submit_started', draft }) etc.
return null;
}
useReducer vs useState for the same counter — when each wins·tsx
// useState wins here — single value, no named transitions.
function CounterA() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount((n) => n + 1)}>+</button>
<span>{count}</span>
<button onClick={() => setCount((n) => n - 1)}>-</button>
</>
);
}
// useReducer wins here — state has rules (can't go below 0, jump must reset).
type CountState = { value: number };
type CountAction =
| { type: "inc" }
| { type: "dec" }
| { type: "jump"; to: number };
function CounterB() {
const [state, dispatch] = useReducer(
(s: CountState, a: CountAction): CountState => {
switch (a.type) {
case "inc": return { value: s.value + 1 };
case "dec": return { value: Math.max(0, s.value - 1) }; // floored
case "jump": return { value: a.to };
}
},
{ value: 0 }
);
return (
<>
<button onClick={() => dispatch({ type: "inc" })}>+</button>
<span>{state.value}</span>
<button onClick={() => dispatch({ type: "dec" })}>-</button>
<button onClick={() => dispatch({ type: "jump", to: 100 })}>jump</button>
</>
);
}
Convert a useState-based todo list (where you track todos, filter, and editingId independently) into a useReducer with action types like add, toggle, remove, set_filter, start_edit, commit_edit, cancel_edit. Use a discriminated union for the action type. Confirm the component logic reads more like the user's mental model (named verbs) than a sequence of setter calls.
Hint
Start by listing every state-mutating event your component handles. Each becomes one action type. The reducer's switch then has one case per event.
Progress
Progress is local-only — sign in to sync across devices.