useReducer Patterns
From many setStates to one reducer
Section titled “From many setStates to one reducer”useState is perfect for independent pieces of state. But when several values change together in response to the same events — and the next state depends on the current state and an action — a reducer consolidates that logic into one pure function.
// The reducer is a pure function: (state, action) => nextState.function tasksReducer(state, action) { switch (action.type) { case "added": return [...state, { id: action.id, text: action.text, done: false }]; case "toggled": return state.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t)); case "deleted": return state.filter((t) => t.id !== action.id); default: throw new Error("unknown action: " + action.type); }}
function TaskApp() { const [tasks, dispatch] = useReducer(tasksReducer, []); // You describe WHAT happened (an action); the reducer decides the next state. return <button onClick={() => dispatch({ type: "added", id: 1, text: "Learn" })}>Add</button>;}useReducer returns the current state and a dispatch function. You don’t compute the next state at the call site — you dispatch an action describing what happened, and the reducer computes the result in one place.
Why prefer a reducer
Section titled “Why prefer a reducer”flowchart TB
subgraph a["Many setState"]
e1["event"] --> s1["setX"]
e1 --> s2["setY"]
e1 --> s3["setZ (logic scattered)"]
end
subgraph b["One reducer"]
e2["event"] --> d["dispatch(action)"] --> r["reducer: all transition logic here"]
end Reach for useReducer over useState when:
- State transitions are complex — many fields update together, or the next value depends on the previous in non-trivial ways.
- You want the logic in one testable place — a reducer is a plain function you can unit-test with no React at all:
reducer(state, action)in,nextStateout. - Actions document intent —
dispatch({ type: "checkout_failed" })reads better than a pile of setters, and gives you a log of what happened.
Proof: a reducer must be pure — StrictMode calls it twice
Section titled “Proof: a reducer must be pure — StrictMode calls it twice”useReducer has the exact same purity contract as useState’s updater form, and Strict Mode enforces it the same way: in development, it calls your reducer twice for each dispatch (with the same state and action) to help surface reducers with side effects — but only commits the result of one call.
import { StrictMode, useReducer } from 'react';
type Action = { type: 'inc' };
export function makeReducer(log: number[]) { return function reducer(state: number, action: Action) { log.push(state); switch (action.type) { case 'inc': return state + 1; default: return state; } };}
export function Counter({ log }: { log: number[] }) { const [count, dispatch] = useReducer(makeReducer(log), 0); return <button onClick={() => dispatch({ type: 'inc' })}>{count}</button>;}
export function StrictCounter({ log }: { log: number[] }) { return ( <StrictMode> <Counter log={log} /> </StrictMode> );}import { render, screen, act, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { Counter, StrictCounter } from '../components/ReducerStrict';
afterEach(cleanup);
test('without StrictMode, the reducer runs once per dispatch', () => { const log: number[] = []; render(<Counter log={log} />); act(() => { screen.getByRole('button').click(); }); expect(log).toEqual([0]); expect(screen.getByRole('button').textContent).toBe('1');});
test('StrictMode calls the reducer twice with the same state, but commits once', () => { const log: number[] = []; render(<StrictCounter log={log} />); act(() => { screen.getByRole('button').click(); }); // Called twice with the SAME starting state (0, 0) — not (0, 1) — proving // it's a double-invoke for purity-checking, not a real double-dispatch. expect(log).toEqual([0, 0]); expect(screen.getByRole('button').textContent).toBe('1');});✓ without StrictMode, the reducer runs once per dispatch✓ StrictMode calls the reducer twice with the same state, but commits once
Test Files 1 passed (1) Tests 2 passed (2)Reducer purity is the same rule as render: given the same state and action, return the same next state, with no side effects and no mutation (return new objects/arrays). The log proves why that matters — both StrictMode calls saw the identical starting state 0, so any side effect in the reducer (a console.log, an analytics call, pushing into an array from outer scope) fires twice for one dispatch.
Proof: mutating state in a reducer doesn’t just risk duplicates — it can silently stop rendering
Section titled “Proof: mutating state in a reducer doesn’t just risk duplicates — it can silently stop rendering”The useReducer reference shows this exact mistake and names the mechanism directly: “Mutating state directly causes React to ignore the update because Object.is comparison determines that the next state is equal to the previous state”:
function reducer(state, action) { switch (action.type) { case 'added_todo': { // 🚩 Mistake: mutating state state.todos.push({ id: nextId++, text: action.text }); return state; } // ... }}This is worse than it looks. Because the reducer returns the exact same top-level object reference, React’s bail-out check (Object.is(prevState, nextState)) says “nothing changed” — so it can skip re-rendering entirely, even though the array was mutated in place underneath.
import { StrictMode, useReducer } from 'react';
type Todo = { id: number; text: string };type State = { todos: Todo[] };type Action = { type: 'added_todo'; text: string };
let nextId = 0;
// ❌ Impure: mutates state.todos directly instead of returning a new array.function impureReducer(state: State, action: Action): State { switch (action.type) { case 'added_todo': { state.todos.push({ id: nextId++, text: action.text }); return state; } default: return state; }}
// ✅ Pure: returns a new state object/array every time.function pureReducer(state: State, action: Action): State { switch (action.type) { case 'added_todo': { return { todos: [...state.todos, { id: nextId++, text: action.text }] }; } default: return state; }}
export function StrictImpureApp() { const [state, dispatch] = useReducer(impureReducer, { todos: [] }); return ( <StrictMode> <button onClick={() => dispatch({ type: 'added_todo', text: 'a' })}>add</button> <span data-testid="count">{state.todos.length}</span> </StrictMode> );}
export function StrictPureApp() { const [state, dispatch] = useReducer(pureReducer, { todos: [] }); return ( <StrictMode> <button onClick={() => dispatch({ type: 'added_todo', text: 'a' })}>add</button> <span data-testid="count">{state.todos.length}</span> </StrictMode> );}import { render, screen, act, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { StrictImpureApp, StrictPureApp } from '../components/ImpureReducer';
afterEach(cleanup);
test('the impure, mutating reducer never re-renders — the count stays stuck', () => { render(<StrictImpureApp />); act(() => { screen.getByRole('button').click(); }); // Same top-level state reference returned every time → React bails out of // the re-render entirely, so the DOM never reflects the mutation. expect(screen.getByTestId('count').textContent).toBe('0');});
test('the pure reducer correctly re-renders once per dispatch, even under StrictMode', () => { render(<StrictPureApp />); act(() => { screen.getByRole('button').click(); }); expect(screen.getByTestId('count').textContent).toBe('1');});✓ the impure, mutating reducer never re-renders — the count stays stuck✓ the pure reducer correctly re-renders once per dispatch, even under StrictMode
Test Files 1 passed (1) Tests 2 passed (2)The mutation didn’t just risk a visible glitch — because Object.is sees the same reference coming back, React silently skips the update altogether, and the button’s displayed count never advances past 0 no matter how many times you click it. Reducer purity isn’t a style preference here: mutating and returning the same reference can make dispatch appear to do nothing.
Lazy initialization for useReducer
Section titled “Lazy initialization for useReducer”Like useState, useReducer accepts a third argument: an initializer function, called only once, with the second argument as its input. Use it when computing the initial state is expensive.
import { useReducer } from 'react';
type State = { count: number };type Action = { type: 'inc' };
function reducer(state: State, action: Action): State { switch (action.type) { case 'inc': return { count: state.count + 1 }; default: return state; }}
export function createInitialState(seed: number, calls: number[]): State { calls.push(seed); // records every time this actually runs return { count: seed };}
export function LazyInitCounter({ seed, calls }: { seed: number; calls: number[] }) { const [state, dispatch] = useReducer(reducer, seed, (s) => createInitialState(s, calls)); return <button onClick={() => dispatch({ type: 'inc' })}>{state.count}</button>;}import { render, screen, act, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { LazyInitCounter } from '../components/LazyInitReducer';
afterEach(cleanup);
test('the initializer function runs exactly once, no matter how many dispatches follow', () => { const calls: number[] = []; render(<LazyInitCounter seed={10} calls={calls} />); act(() => { screen.getByRole('button').click(); }); act(() => { screen.getByRole('button').click(); }); expect(calls).toEqual([10]); expect(screen.getByRole('button').textContent).toBe('12');});✓ the initializer function runs exactly once, no matter how many dispatches follow
Test Files 1 passed (1) Tests 1 passed (1)useReducer(reducer, seed, createInitialState) calls createInitialState(seed) once, on the first render, and ignores it afterward — exactly like useState(() => expensive()), but with the initial argument passed through instead of closed over.
Discriminated-union actions: let tsc catch a missing case
Section titled “Discriminated-union actions: let tsc catch a missing case”Typing Action as a discriminated union (each variant has a distinct literal type field) lets TypeScript narrow action inside each case, and lets you add an exhaustiveness check in default that fails to compile if a case is ever left unhandled:
type Action = | { type: 'increment' } | { type: 'decrement' } | { type: 'reset'; to: number };
function reducer(state: { count: number }, action: Action) { switch (action.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; default: { // If every case above were handled, `action` would be narrowed to // `never` here. Leaving out `reset` makes this assignment fail. const exhaustive: never = action; return exhaustive; } }}error TS2322: Type '{ type: "reset"; to: number; }' is not assignable to type 'never'.That’s the real tsc --strict output for the snippet above with case 'reset' left out — captured from the probe, not paraphrased. Add the missing case and the error disappears:
type Action = | { type: 'increment' } | { type: 'decrement' } | { type: 'reset'; to: number };
export function reducer(state: { count: number }, action: Action): { count: number } { switch (action.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; case 'reset': return { count: action.to }; default: { const exhaustive: never = action; return exhaustive; } }}This check costs one line and catches a real, common mistake — add a fourth action variant six months from now, forget to handle it in every reducer that switches on it, and tsc refuses to build instead of silently falling through to default.
useReducer vs useActionState
Section titled “useReducer vs useActionState”React 19’s useActionState looks superficially similar — it also returns state plus a dispatch-like function, driven by a reducer-shaped function. The difference is what triggers the update: useReducer’s dispatch is synchronous and driven by whatever event you call it from; useActionState’s action function is built for async, pending-aware transitions — it tracks a pending boolean and integrates with form actions and useFormStatus automatically. Reach for useReducer for synchronous, in-memory state transitions (a list, a multi-field form’s local validation state); reach for useActionState when the “action” is inherently asynchronous (a server request) and you want pending/error state for free.
Context + reducer: app state without a library
Section titled “Context + reducer: app state without a library”Combine the two tools from this module and you get lightweight app-wide state management with zero dependencies: a reducer holds the logic, and Context delivers the state and dispatch down the tree.
const TasksContext = createContext(null);const TasksDispatchContext = createContext(null); // split: state vs dispatch
function TasksProvider({ children }) { const [tasks, dispatch] = useReducer(tasksReducer, []); return ( <TasksContext value={tasks}> <TasksDispatchContext value={dispatch}>{children}</TasksDispatchContext> </TasksContext> );}
// Anywhere below: read state and dispatch actions.function useTasks() { return useContext(TasksContext); }function useTasksDispatch() { return useContext(TasksDispatchContext); }Proof: a dispatch-only component never re-renders when the list changes
Section titled “Proof: a dispatch-only component never re-renders when the list changes”dispatch from useReducer is referentially stable for the lifetime of the component — React guarantees it never changes across renders. Combined with splitting state and dispatch into separate contexts, a component that only reads dispatch (never the state) has no reason to ever re-render when the state updates.
import { createContext, useContext, useReducer, memo, type Dispatch, type ReactNode } from 'react';
type Task = { id: number; text: string; done: boolean };type Action = | { type: 'added'; id: number; text: string } | { type: 'toggled'; id: number };
function tasksReducer(state: Task[], action: Action): Task[] { switch (action.type) { case 'added': return [...state, { id: action.id, text: action.text, done: false }]; case 'toggled': return state.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t)); default: return state; }}
const TasksContext = createContext<Task[]>([]);const TasksDispatchContext = createContext<Dispatch<Action> | null>(null);
export function TasksProvider({ children }: { children: ReactNode }) { const [tasks, dispatch] = useReducer(tasksReducer, []); return ( <TasksContext value={tasks}> <TasksDispatchContext value={dispatch}>{children}</TasksDispatchContext> </TasksContext> );}
export const AddTaskButton = memo(function AddTaskButton({ onRender }: { onRender: () => void }) { const dispatch = useContext(TasksDispatchContext)!; onRender(); return ( <button onClick={() => dispatch({ type: 'added', id: Date.now(), text: 'x' })}>add</button> );});
export function TaskList() { const tasks = useContext(TasksContext); return <span data-testid="count">{tasks.length}</span>;}import { render, screen, act, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { TasksProvider, AddTaskButton, TaskList } from '../components/TasksContext';
afterEach(cleanup);
test('a dispatch-only component does not re-render when the task list changes', () => { let renders = 0; render( <TasksProvider> <AddTaskButton onRender={() => renders++} /> <TaskList /> </TasksProvider>, ); const baseline = renders;
act(() => { screen.getByRole('button').click(); }); act(() => { screen.getByRole('button').click(); });
// Two dispatches added two tasks — but the dispatch-only button re-rendered // zero additional times, because `dispatch` never changes reference and it // never reads TasksContext (the state). expect(renders - baseline).toBe(0); expect(screen.getByTestId('count').textContent).toBe('2');});✓ a dispatch-only component does not re-render when the task list changes
Test Files 1 passed (1) Tests 1 passed (1)Splitting state and dispatch into two contexts means components that only dispatch (never read the list) don’t re-render when the list changes — proven above: two dispatches, zero extra renders of the button, while the list itself correctly grew to 2. This pattern covers a surprising amount of “I need Redux” cases without any library.
Mental Model
Section titled “Mental Model”A reducer turns “what changed” into a pure function call, exactly like render itself: same inputs (state, action) in, same output (next state) out, every time, with nothing observable happening on the side. That’s precisely why Strict Mode can call it twice safely to check your work — a pure function’s second call is indistinguishable from its first, except to an observer standing where it shouldn’t be (a console.log, a mutation of the input). If doubling the call ever changes the visible outcome, the function was never pure to begin with — mutating and returning the same reference is the sharpest example: it doesn’t just fail the purity test, it also defeats React’s own change-detection.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
useReducer over many useState | Transition logic centralized, unit-testable with no React | More ceremony for genuinely independent fields |
| Discriminated-union actions + exhaustiveness check | tsc catches a forgotten case at compile time | One extra default branch to write correctly |
Lazy init (useReducer(reducer, arg, init)) | Expensive setup runs exactly once | One more function to structure correctly |
| Context + reducer (split state/dispatch) | Zero-dependency app state, dispatch-only components never re-render | More wiring than a single useState; still no selectors (see the Context lesson) |
useActionState instead | Built-in pending/error tracking for async actions | Wrong tool for purely synchronous, in-memory transitions |
Common Misconceptions
Section titled “Common Misconceptions”- “Strict Mode calling my reducer twice means the action fires twice.” It means the same
(state, action)pair is evaluated twice to check purity; only one result is committed — proven above with the same starting state logged twice, not0then1. - “Mutating and returning
statefrom a reducer is just a style smell.” It can silently disable re-rendering altogether: React’s bail-out check sees the same reference and assumes nothing changed, so the DOM can get stuck exactly where it started — proven above. - “A
default: throw new Error(...)branch is the same as an exhaustiveness check.” It catches an unhandled action at runtime, after shipping.const exhaustive: never = actioncatches a missing case at compile time, before shipping — both are worth having, but they catch the mistake at very different times. - “
useActionStateis justuseReducerwith a new name.” It’s built specifically for async, pending-tracked actions (form submissions, server requests) —useReducerhas no concept ofpendingat all.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting a generated reducer or its wiring, check for these:
- Does the reducer mutate
state(.push,.sort, direct property assignment) and thenreturn state? That’s not just a style nit — it can make React skip the re-render entirely, exactly as proven above. - Is
Actiontyped as a loose shape ({ type: string; payload?: any }) instead of a discriminated union? Without distinct literaltypes,tsccan’t narrow inside eachcase, and an exhaustiveness check indefaultis meaningless. - Does every reducer’s
switchend in either a realdefaultthat throws, or an exhaustiveness check (const _exhaustive: never = action)? Silently falling through toreturn stateon an unrecognized action type hides typos in dispatched action names. - Is
useActionStateused for a synchronous, purely local state transition, oruseReducerused to wrap what’s actually an async server request (reinventingpending/error tracking by hand)? Match the hook to whether the transition is sync or async. - In a context + reducer setup, are state and dispatch delivered through the same context? That defeats the whole point — a component that only dispatches will still re-render on every state change, because it’s still subscribed to the context carrying the state too.
🐛 Spot the bug
function cartReducer(state, action) {switch (action.type) { case "add_item": state.items.push(action.item); state.total += action.item.price; return state; default: return state;}}
function CartApp() {const [cart, dispatch] = useReducer(cartReducer, { items: [], total: 0 });return ( <button onClick={() => dispatch({ type: "add_item", item: { price: 9 } })}> Add — total: {cart.total} </button>);}Show the answer
cartReducer mutates state.items and state.total directly, then returns the exact same state object reference. React’s bail-out check (Object.is) sees no reference change and can skip the re-render entirely — proven above with the identical pattern (ImpureReducer): the displayed total can get stuck at its initial value even though items are actually being pushed into the array underneath. The fix is to return a new object every time: return { items: [...state.items, action.item], total: state.total + action.item.price }; — now every dispatch produces a genuinely new reference, and React reliably re-renders.