Skip to content
React rc hooks 5 min read

useReducer

useReducer is the Hook for managing state whose next value is computed from the previous value through well-defined transitions. Instead of scattering setState calls across event handlers, you describe every change as an action and centralize the update logic in a single pure reducer function. This makes complex state—forms with many fields, undo stacks, carts, wizards—easier to reason about, test, and debug. This page covers the reducer signature, dispatch, when it beats useState, common action patterns, lazy initialization, and combining it with context.

The reducer signature

A reducer is a pure function with the shape (state, action) => newState. It receives the current state and an action object, then returns the next state—never mutating the original. The action is just a plain object; by convention it has a type field and any extra data the update needs.

import { useReducer } from "react";

function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    case "set":
      return { count: action.value };
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });

  return (
    <div>
      <span>{state.count}</span>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "set", value: 0 })}>Reset</button>
    </div>
  );
}

useReducer returns a [state, dispatch] pair. Calling dispatch(action) runs the reducer with the current state and the action, then schedules a re-render with the returned value.

The reducer must be pure: no API calls, no mutation, no Date.now() or random values inside it. Same (state, action) in, same newState out. Side effects belong in event handlers or useEffect.

useReducer vs useState

Both Hooks store state and trigger re-renders. The difference is where the update logic lives. useState is ideal for independent, simple values. useReducer shines when updates are interrelated or follow rules.

ConcernuseStateuseReducer
Best forSimple, independent valuesComplex/related transitions
Update logicInline in handlersCentralized in one reducer
TestabilityTest the componentTest the reducer as a plain function
Multiple sub-valuesSeveral setter callsOne dispatch per action
DebuggingTrace each setterLog actions in one place

A good rule: when several pieces of state always change together, or when “what happened” is clearer than “what the new value is,” reach for useReducer.

A worked example: a shopping cart

Carts are a classic fit—adding, removing, and changing quantities all touch the same list and depend on its current contents.

import { useReducer } from "react";

function cartReducer(items, action) {
  switch (action.type) {
    case "add": {
      const existing = items.find((i) => i.id === action.product.id);
      if (existing) {
        return items.map((i) =>
          i.id === action.product.id ? { ...i, qty: i.qty + 1 } : i
        );
      }
      return [...items, { ...action.product, qty: 1 }];
    }
    case "remove":
      return items.filter((i) => i.id !== action.id);
    case "setQty":
      return items.map((i) =>
        i.id === action.id ? { ...i, qty: Math.max(1, action.qty) } : i
      );
    case "clear":
      return [];
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function Cart() {
  const [items, dispatch] = useReducer(cartReducer, []);
  const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);

  return (
    <div>
      <button
        onClick={() =>
          dispatch({ type: "add", product: { id: 1, name: "Mug", price: 12 } })
        }
      >
        Add Mug
      </button>

      <ul>
        {items.map((i) => (
          <li key={i.id}>
            {i.name} × {i.qty} — ${i.price * i.qty}
            <button onClick={() => dispatch({ type: "remove", id: i.id })}>
              Remove
            </button>
          </li>
        ))}
      </ul>

      <strong>Total: ${total}</strong>
    </div>
  );
}

Output:

// After clicking "Add Mug" twice:
Mug × 2 — $24   [Remove]
Total: $24

Every cart mutation lives in cartReducer. Components just describe intent (add, remove), and the total is derived during render instead of being stored.

Lazy initialization

useReducer accepts an optional third argument: an init function. React calls init(initialArg) once to produce the starting state. This is useful for expensive setup or for reusing the same logic to reset state.

function init(initialCount) {
  return { count: initialCount, history: [] };
}

function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1, history: [...state.history, state.count] };
    case "reset":
      return init(action.payload); // reuse init to reset cleanly
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function Counter({ start = 0 }) {
  const [state, dispatch] = useReducer(reducer, start, init);
  return (
    <button onClick={() => dispatch({ type: "reset", payload: start })}>
      {state.count}
    </button>
  );
}

Passing start as the second argument and init as the third keeps the initial computation out of the render path and lets the reset action recreate the exact initial shape.

Combining with context

Because dispatch is stable—React guarantees its identity never changes between renders—it is perfect for sharing through context without causing extra re-renders. A common pattern splits state and dispatch into two providers so components that only dispatch don’t re-render when state changes.

import { createContext, useContext, useReducer } from "react";

const CartStateContext = createContext(null);
const CartDispatchContext = createContext(null);

export function CartProvider({ children }) {
  const [items, dispatch] = useReducer(cartReducer, []);
  return (
    <CartStateContext.Provider value={items}>
      <CartDispatchContext.Provider value={dispatch}>
        {children}
      </CartDispatchContext.Provider>
    </CartStateContext.Provider>
  );
}

export const useCart = () => useContext(CartStateContext);
export const useCartDispatch = () => useContext(CartDispatchContext);

Now any descendant calls useCartDispatch() to send actions, while useCart() reads the current items. This is a lightweight, dependency-free alternative to Redux for app-wide state.

Keep actions descriptive ("checkout", not "setState"). Action names form a log of what the user did, which is invaluable when debugging with React DevTools.

Best Practices

  • Keep the reducer pure—no mutations, no side effects; always return a new state object or array.
  • Model actions around user intent ("itemAdded") rather than raw setters, so the action log reads like a story.
  • Throw on unknown action types during development to catch typos early instead of silently returning stale state.
  • Derive computed values (totals, filtered lists) during render; store only the minimal source-of-truth in the reducer.
  • Pass dispatch through context to avoid prop-drilling—it’s stable, so it won’t trigger needless re-renders.
  • Use the lazy init argument both for expensive setup and to reuse one function for initialization and reset.
  • Reach for useReducer when several related values change together; stick with useState for simple, independent ones.
Last updated June 14, 2026
Was this helpful?