React – useReducer

July 15, 20265 min readUpdated 8/18/2026

useReducer is useState for state whose updates are a small, fixed set of operations — several of which depend on the previous value. Instead of scattering the logic across event handlers, you put it all in one function.

The same counter, both ways

// useState
const [count, setCount] = useState(0);
<button onClick={() => setCount((c) => c + 1)}>+</button>

// useReducer
const [count, dispatch] = useReducer(counterReducer, 0);
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>

function counterReducer(state: number, action: { type: string }) {
  switch (action.type) {
    case 'INCREMENT': return state + 1;
    default: return state;
  }
}

For a counter this is strictly worse, and that is worth saying plainly: useReducer is more code and you should not reach for it by default.

When it earns its keep

The cart is the case it was built for. Six operations, most of which need the current cart to decide what to do — adding a pizza has to check whether an identical configuration is already there and bump the quantity instead of adding a second line. Written as useState calls, that logic ends up spread across the components that trigger it. As a reducer it is one function you can read top to bottom, and test without rendering anything.

The signal to switch: several pieces of state that must stay consistent with each other, and updates that depend on the previous value.

Actions

An action is an object describing what happened. Typing it as a discriminated union is what makes this pattern pleasant in TypeScript:

/**
 * Every way the cart can change, as a discriminated union.
 *
 * TypeScript narrows `action.payload` based on `action.type`, so the reducer's switch is
 * exhaustively type-checked: add a new action and forget to handle it, and the build fails.
 */
type CartAction =
  | { type: 'ADD_ITEM'; payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: { lineId: string } }
  | { type: 'SET_QUANTITY'; payload: { lineId: string; quantity: number } }
  | { type: 'SET_ORDER_TYPE'; payload: { orderType: OrderType } }
  | { type: 'HYDRATE'; payload: CartState }
  | { type: 'CLEAR' };

Inside case 'REMOVE_ITEM', TypeScript knows action.payload has a lineId and nothing else. Inside case 'CLEAR' it knows there is no payload at all. That narrowing is free and it catches real mistakes.

Name actions after what happened, not what to set. ADD_ITEM, not SET_ITEMS — the reducer's job is to decide what "add an item" means, and a caller that already knows the answer is a caller doing the reducer's work.

The reducer

(state, action) => newState. Pure: same inputs, same output, no side effects, no mutation.

export function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find((item) => isSameConfiguration(item, action.payload));

      if (existing) {
        // Same configuration already in the cart: bump the quantity instead of adding a line.
        return {
          ...state,
          items: state.items.map((item) =>
            item.lineId === existing.lineId
              ? { ...item, quantity: item.quantity + action.payload.quantity }
              : item,
          ),
        };
      }

      return { ...state, items: [...state.items, action.payload] };
    }

    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter((item) => item.lineId !== action.payload.lineId),
      };

    case 'SET_QUANTITY': {
      // Dropping to zero removes the line — it is what a user expects from a "−" button.
      if (action.payload.quantity <= 0) {
        return {
          ...state,
          items: state.items.filter((item) => item.lineId !== action.payload.lineId),
        };
      }
      return {
        ...state,
        items: state.items.map((item) =>
          item.lineId === action.payload.lineId
            ? { ...item, quantity: action.payload.quantity }
            : item,
        ),
      };
    }

    case 'SET_ORDER_TYPE':
      return { ...state, orderType: action.payload.orderType };

    /** Replace everything with what the server had saved. */
    case 'HYDRATE':
      return action.payload;

    case 'CLEAR':
      return { ...state, items: [] };

    default:
      return state;
  }
}

Every branch returns a new object. Not one calls push or assigns to items[i], because React compares by reference — Updating State Correctly is the whole story there.

Notice how much domain logic that is, all in one place: the merge rule for identical pizzas, the zero-removes-the-line rule, what "clear" keeps. None of it is in a component.

Wiring it up

const [state, dispatch] = useReducer(cartReducer, initialState);

Same shape as useState: current value, and a way to change it. The reducer function goes first, the initial state second.

dispatch has a property useState's setter does not: React guarantees it never changes. That is why the callbacks wrapping it can have empty dependency arrays, and why a reducer inside a context provider is easier to keep stable than several useState setters:

const removeItem = useCallback((lineId: string) => {
  dispatch({ type: 'REMOVE_ITEM', payload: { lineId } });
}, []);                                                    // ← [] is correct, not a shortcut

const setQuantity = useCallback((lineId: string, quantity: number) => {
  dispatch({ type: 'SET_QUANTITY', payload: { lineId, quantity } });
}, []);

Reducers cannot do side effects

A reducer must be pure, so the request that saves the cart to the server cannot live in it. That belongs in an effect watching the state:

useEffect(() => {
  // Never write before hydrating — that would overwrite the saved cart with an empty one.
  if (!hydrated) return;

  // Debounced: clicking "+" three times quickly is one write, not three.
  const timer = window.setTimeout(() => {
    void (async () => {
      try {
        /* … PUT the whole cart … */
      } catch {
        // A failed save must not break the page.
      }
    })();
  }, 300);

  return () => window.clearTimeout(timer);
}, [state, hydrated]);

This is a good example of the separation the pattern buys you: the reducer decides what the cart is, the effect deals with keeping the server in step, and neither knows about the other.

Reducer plus context

Put a reducer in a context provider and you have the architecture Redux made famous, with nothing installed. The pizza cart is exactly this:

export function CartProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  const addItem = useCallback((input: AddItemInput) => {
    dispatch({ type: 'ADD_ITEM', payload: { lineId: crypto.randomUUID(), /* … */ } });
  }, []);

  const value = useMemo<CartContextValue>(
    () => ({ items: state.items, totals: calculateTotals(state.items, state.orderType), addItem, /* … */ }),
    [state.items, state.orderType, addItem /* … */],
  );

  return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}

Consumers never see dispatch. They call addItem(…), and the action shape stays an implementation detail. That is worth doing: it means changing the reducer never touches a component.

Redux, and Whether You Need It compares this directly against the real thing.

Testing

The payoff people forget. A reducer is a plain function, so its tests need no React at all:

test('adding an identical configuration bumps the quantity instead of adding a line', () => {
  const first = { ...pepperoni, lineId: 'a', quantity: 1 };
  const again = { ...pepperoni, lineId: 'b', quantity: 2 };

  const state = cartReducer(
    cartReducer(initialState, { type: 'ADD_ITEM', payload: first }),
    { type: 'ADD_ITEM', payload: again },
  );

  expect(state.items).toHaveLength(1);
  expect(state.items[0].quantity).toBe(3);
});

No render, no mock, no DOM. That is why cartReducer is exported.

Next

Custom Hooks — the useCart wrapper this post kept referring to.