Skip to main content
Back to module
React Fundamentals · State and Events

Reducers for complex state

Reading44mLesson 3 of 3 in this module

Collect state changes into a pure reducer with useReducer, and test it as a plain function.

Why it matters

As a component grows, its event handlers multiply: add an item, remove it, change a quantity, clear the cart, apply a coupon. When each handler pokes at several useState values, the logic gets scattered and hard to test. A reducer gathers every state change into one pure function you can read top to bottom and test without rendering anything.

The idea

A reducer takes the current state and an action (a plain object describing what happened) and returns the next state:

function counterReducer(state, action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    case "reset":
      return { count: 0 };
    default:
      return state;
  }
}

counterReducer({ count: 2 }, { type: "increment" }); // { count: 3 }

The name comes from Array.prototype.reduce: replaying a list of actions through the reducer gives you the final state.

const actions = [{ type: "increment" }, { type: "increment" }, { type: "decrement" }];
actions.reduce(counterReducer, { count: 0 }); // { count: 1 }

Rules for reducers

  1. Pure. Same state and action, same result. No fetching, no random numbers, no timers, no console.log-driven logic.
  2. Never mutate. Return new objects and arrays. React compares references to decide whether something changed.
  3. Unknown actions return the same state object, so nothing re-renders. (Some teams throw an error instead, to catch typos.)
  4. Actions describe what happened, not how to change state: { type: "added", item } reads better than { type: "setItems", items }.

useReducer in a component

function Counter() {
  const [state, dispatch] = React.useReducer(counterReducer, { count: 0 });
  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
    </div>
  );
}

useReducer(reducer, initialState) returns the current state and a dispatch function. Event handlers become tiny: they just describe what happened.

A realistic example: a shopping cart

function cartReducer(state, action) {
  switch (action.type) {
    case "add": {
      const exists = state.items.some((i) => i.id === action.item.id);
      if (exists) {
        return {
          ...state,
          items: state.items.map((i) =>
            i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i
          ),
        };
      }
      return { ...state, items: [...state.items, { ...action.item, qty: 1 }] };
    }
    case "remove":
      return { ...state, items: state.items.filter((i) => i.id !== action.id) };
    case "clear":
      return { ...state, items: [] };
    default:
      return state;
  }
}

The patterns to remember:

  • Add to an array: [...items, newItem].
  • Remove: items.filter(...).
  • Update one item: items.map((i) => (i.id === id ? { ...i, changed } : i)).
  • Update a field of the state object: { ...state, field: value }.

Note the braces around case "add": { ... }. They give the const its own block, so two cases can each declare a variable with the same name.

Testing a reducer

Because a reducer is a plain function, testing it needs no browser and no React:

const start = Object.freeze({ items: [] });
const once = cartReducer(start, { type: "add", item: { id: "pen", price: 2 } });
const twice = cartReducer(once, { type: "add", item: { id: "pen", price: 2 } });

console.assert(once.items[0].qty === 1);
console.assert(twice.items.length === 1 && twice.items[0].qty === 2);
console.assert(start.items.length === 0); // unchanged

Object.freeze is a handy trick: in strict mode, any attempt to mutate the frozen state throws, so a hidden mutation bug shows up immediately. (It freezes only one level; freeze nested objects too if you want full protection.) In a real project you would write these as Vitest or Jest test cases.

Derived values stay outside

Do not store a cart total in the reducer state; compute it:

function cartTotal(state) {
  return state.items.reduce((sum, i) => sum + i.price * i.qty, 0);
}

useState or useReducer?

Use useState whenUse useReducer when
One or two independent valuesSeveral values change together
Updates are simple (set, toggle)Many kinds of updates
Logic is shortYou want to unit-test transitions

Both are fine; you can start with useState and switch later. Libraries such as Redux use the same reducer idea for app-wide state.

Common mistakes

  • Mutating: state.items.push(item); return state; returns the same reference, so React may not re-render.
  • Forgetting default: return state, so unknown actions return undefined and wipe the state.
  • Doing side effects (API calls) inside the reducer. Do them in event handlers or effects, then dispatch the result.

Recap

  • A reducer is (state, action) => newState: pure and never mutating.
  • useReducer gives you [state, dispatch]; handlers dispatch action objects.
  • Use spread, map and filter to build new state.
  • Reducers are easy to test as plain functions; keep derived values outside them.

Try it

Open the Do tab to write and test a cart reducer with add, remove, set-quantity and clear actions, plus a cartTotal helper.

Also in this lesson: