useReducer Hook
Manage complex state transitions with a reducer function and dispatched actions.
Search across all documentation pages
Manage complex state transitions with a reducer function and dispatched actions.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
const [state, dispatch] = useReducer(reducer, initialState);
// With lazy initializer
const [state, dispatch] = useReducer(reducer, initialArg, init);
// Dispatch an action
dispatch({ type: "increment" });
dispatch({ type: "setName", payload: "Alice" });When to reach for this: Your state has multiple sub-values, transitions depend on the previous state, or you want to centralize state logic for testability.
"use client";
import { useReducer } from "react";
type State = { count: number; step: number };
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "setStep"; payload: number }
| { type: "reset" };
const initialState: State = { count: 0, step: 1 };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { ...state, count: state.count + state.step };
case "decrement":
return { ...state, count: state.count - state.step };
case "setStep":
return { ...state, step: action.payload };
case "reset":
return initialState;
default:
return state;
}
}
export function StepCounter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div className="space-y-3">
<div className="flex items-center gap-4">
<button onClick={() => dispatch({ type: "decrement" })} className="px-3 py-1 border rounded">
−
</button>
<span className="text-xl font-mono w-16 text-center">{state.count}</span>
<button onClick={() => dispatch({ type: "increment" })} className="px-3 py-1 border rounded">
+
</button>
</div>
<label className="flex items-center gap-2 text-sm">
Step:
<input
type="number"
value={state.step}
onChange={(e) => dispatch({ type: "setStep", payload: Number(e.target.value) })}
className="w-16 border rounded px-2 py-1"
/>
</label>
<button onClick={() => dispatch({ type: "reset" })} className="text-sm text-blue-600 underline">
Reset
</button>
</div>
);
}What this demonstrates:
count and step)dispatch is stable across renders and safe to pass to children without useCallbackuseReducer accepts a pure reducer function (state, action) => newState and an initial statedispatch(action) sends the action through the reducer and triggers a re-render with the new statedispatch identity is stable - it never changes between rendersuseState, React batches multiple dispatches within the same event handler into a single re-render| Parameter | Type | Description |
|---|---|---|
reducer | (state: S, action: A) => S | Pure function that computes new state from current state and action |
initialArg | S or I | Initial state, or argument passed to the init function |
init | (initialArg: I) => S | Optional lazy initializer function |
| Return | Type | Description |
|---|---|---|
state | S | Current state value |
dispatch | (action: A) => void | Function to send actions to the reducer |
With lazy initializer:
function init(initialCount: number): State {
return { count: initialCount, step: 1 };
}
const [state, dispatch] = useReducer(reducer, 0, init);Reducer with Immer for cleaner updates:
import { useImmerReducer } from "use-immer";
function reducer(draft: State, action: Action) {
switch (action.type) {
case "addTodo":
draft.todos.push({ id: Date.now(), text: action.payload, done: false });
break;
case "toggleTodo":
const todo = draft.todos.find((t) => t.id === action.payload);
if (todo) todo.done = !todo.done;
break;
}
}Pair with context for global state:
const StateContext = createContext<State>(initialState);
const DispatchContext = createContext<Dispatch<Action>>(() => {});
export function AppProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}// Discriminated union for actions - TypeScript narrows inside switch cases
type Action =
| { type: "add"; payload: string }
| { type: "remove"; payload: number }
| { type: "clear" };
// Generic useReducer picks up types automatically
const [state, dispatch] = useReducer(reducer, initialState);
// dispatch is typed as Dispatch<Action>Mutating state directly - Modifying state.count++ inside the reducer won't trigger a re-render and corrupts your state. Fix: Always return a new object: { ...state, count: state.count + 1 }.
Side effects in the reducer - Fetching data or writing to localStorage inside the reducer breaks React's rendering model. Fix: Keep the reducer pure; run side effects in useEffect or event handlers.
Forgetting the default case - If an unrecognized action is dispatched and no default case returns state, you get undefined. Fix: Always include default: return state in your switch.
Over-engineering simple state - Using useReducer for a single boolean or number adds unnecessary complexity. Fix: Use useState for simple, independent values.
| Alternative | Use When | Don't Use When |
|---|---|---|
useState | One or two independent state values | Multiple related values with complex transitions |
| Zustand | Shared state across many components with selectors | State is local to one component tree |
| XState | You need formal state machines with guards and transitions | Simple CRUD operations |
useActionState (React 19) | State transitions tied to form submissions | General client-side state management |
Why not just always use useReducer? For a single toggle or counter, useState is simpler and more readable. Reach for useReducer when you have 3+ related state values or the next state depends on both the current state and an action payload.
useReducer centralizes related state transitions in a single pure function.reducer(state, action) and assert the result.useState calls.dispatch function once and returns the same reference on every render.dispatch to child components without useCallback.React.memo will not re-render due to dispatch changing.state.count++ modifies the existing object without creating a new reference.Object.is to detect changes, so it won't see the mutation and won't re-render.{ ...state, count: state.count + 1 }.useEffect or event handlers that dispatch actions.type Action =
| { type: "add"; payload: string }
| { type: "remove"; payload: number }
| { type: "clear" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "add":
// action.payload is string here
return { ...state, items: [...state.items, action.payload] };
case "remove":
// action.payload is number here
return { ...state, items: state.items.filter((_, i) => i !== action.payload) };
case "clear":
return { ...state, items: [] };
}
}useReducer(reducer, initialArg, init) is a function that computes initial state from initialArg.useReducer.default: return state, the reducer returns undefined.undefined, breaking the component.default: return state as a safety net.useState for one or two independent values (a toggle, a counter).useReducer when you have 3+ related values, complex transitions, or want testable state logic.useReducer is clearer.import { useImmerReducer } from "use-immer";
function reducer(draft: State, action: Action) {
switch (action.type) {
case "addTodo":
draft.todos.push({ id: Date.now(), text: action.payload, done: false });
break;
case "toggleTodo":
const todo = draft.todos.find(t => t.id === action.payload);
if (todo) todo.done = !todo.done;
break;
}
}import { createContext, Dispatch } from "react";
const DispatchContext = createContext<Dispatch<Action>>(() => {});
// Consumers get a typed dispatch:
// dispatch({ type: "add", payload: "item" }) -- OK
// dispatch({ type: "unknown" }) -- TypeScript erroruseReducer for global state managementuseReducer in a custom hook to encapsulate domain logicReviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥