useReducer: Actions, Dispatch, and Predictable State Transitions
Learn to model complex component state as a pure reducer function driven by dispatched actions, using discriminated unions for type safety.
useState is fine for independent pieces of state, but once a component's state has several related fields that change together — or state transitions depend on the *previous* state in ways that are easy to get subtly wrong with several separate setState calls — useReducer centralizes the update logic into one pure function, making the set of valid transitions explicit and testable in isolation.
The reducer vocabulary: state, action, dispatch, reducer
useReducer(reducer, initialState) returns a [state, dispatch] pair. state is the current value, exactly like useState's first element. dispatch is a function you call with an action object to request a state change — it does not change state directly itself, it hands the action to the reducer. The reducer is a pure function with the signature (state, action) => newState that you write yourself: given the current state and an action describing what happened, it computes and returns the next state.
Calling dispatch({ type: 'increment' }) triggers React to call reducer(currentState, { type: 'increment' }), and whatever that call returns becomes the new state, which then triggers a re-render if it differs from the previous state (by reference, just like useState). The component never mutates state directly — every change is described as an action and funneled through the single reducer function, which is what makes the set of possible transitions easy to audit in one place instead of scattered across many onClick handlers.
This is the same pattern Redux popularized, and useReducer is effectively 'Redux's core idea, local to one component, with no extra library.' For state shared across many distant components, this same reducer function can be paired with Context to distribute both the state and the dispatch function, avoiding prop drilling while keeping Redux-style predictability.
Initial state and lazy initialization
The simplest form, useReducer(reducer, initialState), computes initialState on every render of the parent (even though it's only used once, on mount) — usually fine for a cheap literal like { count: 0 }, but wasteful if computing the initial state is itself expensive (parsing a large JSON blob, reading and transforming localStorage, etc.).
The three-argument form, useReducer(reducer, initialArg, init), defers that cost: React calls init(initialArg) exactly once, on mount, to produce the actual initial state, and never calls it again on subsequent renders. This mirrors passing a function to useState's initializer for the same reason — lazy initialization avoids repeating expensive setup work on every render.
Lazy initialization is also useful for resettable state: a common pattern is a reducer that handles a { type: 'reset' } action by calling the same init function again, so 'reset to initial state' and 'compute initial state on mount' share exactly one code path instead of two.
Pure reducers and discriminated union actions
A reducer must be a pure function: given the same state and action, it must always return the same result, with no side effects (no API calls, no timers, no mutating its arguments, no reading external mutable values like Date.now() or Math.random() directly inside it). Side effects that need to happen in response to an action — like firing a network request — belong in a useEffect that watches the resulting state, or in the event handler that calls dispatch, not inside the reducer itself.
In TypeScript, the idiomatic way to type actions is a discriminated union: an action type with a type field whose literal string values distinguish each variant, plus whatever payload each variant needs. type Action = { type: 'increment' } | { type: 'decrement' } | { type: 'set'; payload: number }; — inside a switch (action.type), TypeScript narrows action to the exact variant matching each case, so action.payload is only accessible (and type-checked) inside the 'set' branch, catching typos and missing-field bugs at compile time rather than at runtime.
Because a reducer is pure and takes no arguments beyond state and action, it's trivial to unit test without rendering any component at all: call reducer(someState, someAction) directly and assert on the returned value. This is one of useReducer's most underrated benefits — state transition logic that would otherwise be buried inside event handlers (and require full component rendering to test) becomes a plain function you can test exhaustively with a table of state/action/expected-output triples.
A typed reducer with discriminated union actions and lazy init
Each case returns a brand-new state object rather than mutating state.items in place — immutability is required for React to detect the change. The init function is reused by both the initial mount (via the third argument to useReducer) and the 'reset' action, keeping 'what does an empty cart look like' defined in exactly one place.
type CartItem = { id: string; qty: number; price: number };
type CartState = { items: CartItem[]; total: number };
type CartAction =
| { type: 'add'; item: CartItem }
| { type: 'remove'; id: string }
| { type: 'setQty'; id: string; qty: number }
| { type: 'reset' };
function computeTotal(items: CartItem[]): number {
return items.reduce((sum, i) => sum + i.price * i.qty, 0);
}
function init(startingItems: CartItem[]): CartState {
return { items: startingItems, total: computeTotal(startingItems) };
}
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'add': {
const items = [...state.items, action.item];
return { items, total: computeTotal(items) };
}
case 'remove': {
const items = state.items.filter((i) => i.id !== action.id);
return { items, total: computeTotal(items) };
}
case 'setQty': {
const items = state.items.map((i) =>
i.id === action.id ? { ...i, qty: action.qty } : i
);
return { items, total: computeTotal(items) };
}
case 'reset':
return init([]);
default:
return state;
}
}
function Cart({ startingItems }: { star
…Code Examples
The button component doesn't know or care how adding an item affects the total — it just describes what happened ('add this item') and the reducer owns the logic for how that changes state.
dispatch({ type: 'add', item }) Because cartReducer is a pure function, it can be called directly with hand-built state and action objects and checked against expected output — no component rendering, no DOM, no async setup required. Expected result: Both assertions pass silently; a failing one would log to the console.
function AddToCartButton({ item }: { item: CartItem }) {
const dispatch = useContext(CartDispatchContext); // dispatch shared via Context
return (
<button onClick={() => dispatch({ type: 'add', item })}>
Add to cart
</button>
);
}// plain unit test, no React Testing Library needed
const initial: CartState = { items: [], total: 0 };
const afterAdd = cartReducer(initial, {
type: 'add',
item: { id: '1', qty: 2, price: 10 },
});
console.assert(afterAdd.total === 20, 'total should be 20');
console.assert(afterAdd.items.length === 1, 'should have 1 item');The useReducer data flow
Every state change is funneled through one pure function, making the full set of valid transitions auditable in a single place.
UI Event (e.g. button click)
|
v
dispatch(action) e.g. { type: 'add', item }
|
v
Reducer(state, action) -> New State (pure function, no side effects)
|
v
React re-renders with the new stateImportant Rules
Reducers must be pure: same state + action always produces the same new state, with no side effects and no mutation of the inputs.
Type actions as a discriminated union keyed by a type field so TypeScript narrows each case and catches missing/incorrect payload fields at compile time.
Use the lazy-initializer third argument to useReducer when computing the initial state is expensive, or when you want 'reset' to reuse the same initialization logic.
Keep side effects (API calls, timers, logging) out of the reducer — trigger them from the event handler that dispatches, or from a useEffect that watches the resulting state.
Prefer useReducer over several related useState calls once state fields change together or transitions depend on carefully reasoning about the previous state.
Where useReducer earns its place over useState
Multi-step forms with several interdependent fields and validation state, shopping carts with add/remove/update-quantity operations, undo/redo history stacks, and any state machine with a small set of well-defined transitions (like a video player's playing/paused/buffering/error states) are the classic cases where useReducer's centralized, testable transition logic pays off compared to juggling several separate useState calls that must always be updated together correctly.
function reducer(state: State, action: Action): State { if (action.type === 'add') { state.items.push(action.item); // mutating the array in place return state; } return state; } function reducer(state: State, action: Action): State { if (action.type === 'add') { return {...state, items: [...state.items, action.item] }; } return state; }
Mutating state.items in place and returning the same object reference means React sees no change (state === newState by reference) and may skip re-rendering, even though the underlying array did change.
function reducer(state: State, action: Action): State { if (action.type === 'addTimestamped') { return { ...state, createdAt: Date.now() }; // impure: depends on external mutable value } return state; }
// compute Date.now() in the dispatching event handler and pass it as part of the action dispatch({ type: 'addTimestamped', createdAt: Date.now() }); function reducer(state: State, action: Action): State { if (action.type === 'addTimestamped') { return { ...state, createdAt: action.createdAt }; } return state; }
Calling Date.now() (or Math.random(), or reading any external mutable value) inside the reducer makes it impure — the same state and action no longer reliably produce the same result, which breaks testability and can cause subtle bugs under React's concurrent rendering, which may call a reducer more than once for a single update.
[multiple-choice] What must always be true of a reducer function passed to useReducer? A. It must return a Promise B. It must be pure: no side effects, no mutation, deterministic given the same state and action C. It must call dispatch internally D. It must use async/await
B. It must be pure: no side effects, no mutation, deterministic given the same state and action — Reducers must be pure functions — React may call them more than once for a single update under concurrent rendering, and side effects or mutation would produce inconsistent or buggy results.
[multiple-choice] Why use a discriminated union for the Action type instead of a loosely typed object like { type: string; payload?: any }? A. It makes the code run faster at runtime B. TypeScript narrows the action inside each switch case, so payload fields are only accessible and type-checked where they're actually valid C. It's required by the useReducer API D. It removes the need for a switch statement entirely
B. TypeScript narrows the action inside each switch case, so payload fields are only accessible and type-checked where they're actually valid — A discriminated union lets TypeScript narrow action to the exact variant matching each case, catching typos and missing/incorrect payload fields at compile time instead of at runtime.
[true-false] True or false: the third, lazy-initializer argument to useReducer is called on every render. A. True B. False
B. False — The lazy initializer function is called exactly once, on mount, to compute the initial state — it is not re-invoked on subsequent renders, which is the whole point of using it for expensive initial-state computation.
Write a discriminated union Action type and a pure reducer for a simple traffic light with states 'red' | 'yellow' | 'green' that only supports a single action, 'next', cycling red -> green -> yellow -> red.
type Light = 'red' | 'green' | 'yellow'; type Action = { type: 'next' }; function lightReducer(state: Light, action: Action): Light { switch (action.type) { case 'next': if (state === 'red') return 'green'; if (state === 'green') return 'yellow'; return 'red'; } }
The action type can be as simple as { type: 'next' } since there's only one transition kind; the reducer's switch is on the current state, not the action.