React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

statereducerdispatchcomplex-statehooks

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 (count and step)
  • Discriminated union types for type-safe actions
  • The reducer is a pure function — easy to test in isolation
  • dispatch is stable across renders and safe to pass to children without useCallback

Deep Dive

How It Works

  • useReducer accepts a pure reducer function (state, action) => newState and an initial state
  • Calling dispatch(action) sends the action through the reducer and triggers a re-render with the new state
  • React guarantees dispatch identity 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

ParameterTypeDescription
reducer(state: S, action: A) => SPure function that computes new state from current state and action
initialArgS or IInitial state, or argument passed to the init function
init(initialArg: I) => SOptional lazy initializer function
ReturnTypeDescription
stateSCurrent state value
dispatch(action: A) => voidFunction 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 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.

Alternatives

AlternativeUse WhenDon't Use When
useStateOne or two independent state valuesMultiple related values with complex transitions
ZustandShared state across many components with selectorsState is local to one component tree
XStateYou need formal state machines with guards and transitionsSimple CRUD operations
useActionState (React 19)State transitions tied to form submissionsGeneral 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?
  • useReducer centralizes 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 useState calls.
Why is the dispatch function guaranteed to be stable across renders?
  • React creates the dispatch function once and returns the same reference on every render.
  • This means you can safely pass dispatch to child components without useCallback.
  • Children wrapped in React.memo will not re-render due to dispatch changing.
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.is to 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 useEffect or 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 from initialArg.
  • 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 returns undefined.
  • This replaces your entire state with undefined, breaking the component.
  • Always include default: return state as a safety net.
When should I use useReducer vs useState?
  • Use useState for one or two independent values (a toggle, a counter).
  • Use useReducer when you have 3+ related values, complex transitions, or want testable state logic.
  • If state transitions depend on both current state and an action payload, useReducer is 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 error
  • useState — simpler alternative for independent state values
  • useContext — pair with useReducer for global state management
  • useActionState — React 19 hook for form-driven state transitions
  • Custom Hooks — wrap useReducer in a custom hook to encapsulate domain logic