useReducer Hook
Manage complex state transitions with a reducer function and dispatched actions.
Recipe
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.
Working Example
"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:
- A reducer managing multiple related state values (
countandstep) - Discriminated union types for type-safe actions
- The reducer is a pure function — easy to test in isolation
dispatchis stable across renders and safe to pass to children withoutuseCallback
Deep Dive
How It Works
useReduceraccepts a pure reducer function(state, action) => newStateand an initial state- Calling
dispatch(action)sends the action through the reducer and triggers a re-render with the new state - React guarantees
dispatchidentity is stable — it never changes between renders - Like
useState, React batches multiple dispatches within the same event handler into a single re-render - The reducer runs during rendering, so it must be pure — no side effects, no mutations
Parameters & Return Values
| 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 |
Variations
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>
);
}TypeScript Notes
// 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>Gotchas
-
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
useEffector event handlers. -
Forgetting the default case — If an unrecognized action is dispatched and no default case returns
state, you getundefined. Fix: Always includedefault: return statein your switch. -
Over-engineering simple state — Using
useReducerfor a single boolean or number adds unnecessary complexity. Fix: UseuseStatefor simple, independent values.
Alternatives
| 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.
FAQs
What is the key advantage of useReducer over multiple useState calls?
useReducercentralizes related state transitions in a single pure function.- The reducer is easy to test in isolation -- just call
reducer(state, action)and assert the result. - It prevents impossible state combinations that can occur with independent
useStatecalls.
Why is the dispatch function guaranteed to be stable across renders?
- React creates the
dispatchfunction once and returns the same reference on every render. - This means you can safely pass
dispatchto child components withoutuseCallback. - Children wrapped in
React.memowill not re-render due todispatchchanging.
Gotcha: What happens if I mutate state directly inside the reducer?
- Mutating
state.count++modifies the existing object without creating a new reference. - React uses
Object.isto detect changes, so it won't see the mutation and won't re-render. - Always return a new object:
{ ...state, count: state.count + 1 }.
Can I run side effects (API calls, localStorage) inside a reducer?
- No. Reducers must be pure functions -- no side effects, no async operations, no mutations.
- The reducer runs during rendering, so impure operations break React's model.
- Run side effects in
useEffector event handlers that dispatch actions.
How do I type actions with TypeScript discriminated unions?
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: [] };
}
}What is a lazy initializer and when should I use it with useReducer?
- The third argument to
useReducer(reducer, initialArg, init)is a function that computes initial state frominitialArg. - It runs only once on mount, avoiding expensive initialization on every render.
- Use it when initial state requires computation, such as parsing props or reading from storage.
How do I pair useReducer with useContext for global state?
- Create two contexts: one for state, one for dispatch.
- Provide both from a parent component that calls
useReducer. - Splitting them prevents components that only dispatch from re-rendering when state changes.
Gotcha: What happens if I forget the default case in my reducer switch?
- If an unrecognized action is dispatched and there is no
default: return state, the reducer returnsundefined. - This replaces your entire state with
undefined, breaking the component. - Always include
default: return stateas a safety net.
When should I use useReducer vs useState?
- Use
useStatefor one or two independent values (a toggle, a counter). - Use
useReducerwhen you have 3+ related values, complex transitions, or want testable state logic. - If state transitions depend on both current state and an action payload,
useReduceris clearer.
How does useReducer work with Immer for cleaner immutable 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;
}
}- Immer lets you write "mutating" code that produces immutable updates under the hood.
How do I type the dispatch function when passing it through context in TypeScript?
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 errorRelated
- useState — simpler alternative for independent state values
- useContext — pair with
useReducerfor global state management - useActionState — React 19 hook for form-driven state transitions
- Custom Hooks — wrap
useReducerin a custom hook to encapsulate domain logic