useReducer

React · Lesson 18 of 29 6 min read Updated

As a component grows, its event handlers multiply: add an item, remove it, change the quantity, apply a coupon, clear everything. Each handler updates state in its own way, and the logic ends up scattered across the component. The useReducer hook lets you collect all of those updates into one function, called a reducer, that lives outside the component.

In this lesson you will write a reducer, dispatch actions to it, see why reducers are easy to test, and learn how to choose between useState and useReducer.

From handlers to actions

With useState, each handler says how to change the state. With a reducer, handlers only say what happened, by dispatching an action: a plain object that describes the event, usually with a type field. The reducer then decides how the state changes.

React JSX
// useState: the handler contains the update logic
setItems(items.filter((i) => i.id !== id));

// useReducer: the handler only reports the event
dispatch({ type: "removed", id });

A reducer is a function (state, action) => newState. It receives the current state and the action, and returns the next state. Like rendering, it must be pure: no mutation, no network requests, no random values, just a new state calculated from its inputs.

A canteen order with useReducer

React JSXTry it yourself
import { useReducer } from "react";
import { createRoot } from "react-dom/client";

const menu = [
  { id: "idli", name: "Idli (2 pcs)", price: 40 },
  { id: "dosa", name: "Masala dosa", price: 70 },
  { id: "coffee", name: "Filter coffee", price: 25 },
];

function orderReducer(order, action) {
  switch (action.type) {
    case "added": {
      const qty = (order[action.id] ?? 0) + 1;
      return { ...order, [action.id]: qty };
    }
    case "removed": {
      const qty = (order[action.id] ?? 0) - 1;
      if (qty <= 0) {
        const { [action.id]: _gone, ...rest } = order;
        return rest;
      }
      return { ...order, [action.id]: qty };
    }
    case "cleared":
      return {};
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function Canteen() {
  const [order, dispatch] = useReducer(orderReducer, {});
  const total = menu.reduce((sum, m) => sum + (order[m.id] ?? 0) * m.price, 0);

  return (
    <div>
      <h3>Kochi college canteen</h3>
      {menu.map((m) => (
        <p key={m.id}>
          {m.name} ₹{m.price}{" "}
          <button onClick={() => dispatch({ type: "removed", id: m.id })}>-</button>{" "}
          {order[m.id] ?? 0}{" "}
          <button onClick={() => dispatch({ type: "added", id: m.id })}>+</button>
        </p>
      ))}
      <p><strong>Total: ₹{total}</strong></p>
      <button onClick={() => dispatch({ type: "cleared" })}>Clear order</button>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<Canteen />);

useReducer(orderReducer, {}) takes the reducer and the initial state, and returns the current state and a dispatch function. When you call dispatch(action), React calls orderReducer(currentState, action), stores the result and re-renders, just as a setter would.

The switch statement is the usual way to write reducers: one case per action type. Wrapping each case in braces { } lets you declare const variables inside it. The default case throws for unknown types, so a typo such as "add" instead of "added" fails loudly instead of being ignored. Name action types after what happened ("added", "cleared"), not after commands.

Reducers are easy to test

Because a reducer is an ordinary pure function that knows nothing about React, you can call it directly and check the results. This is a big advantage when the logic is important, such as money calculations:

React JSXTry it yourself
function walletReducer(wallet, action) {
  switch (action.type) {
    case "credited":
      return { ...wallet, balance: wallet.balance + action.amount, history: [...wallet.history, `+${action.amount}`] };
    case "debited":
      if (action.amount > wallet.balance) {
        return { ...wallet, error: "Insufficient balance" };
      }
      return { ...wallet, balance: wallet.balance - action.amount, error: "", history: [...wallet.history, `-${action.amount}`] };
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

let wallet = { balance: 500, history: [], error: "" };
wallet = walletReducer(wallet, { type: "credited", amount: 1000 });
wallet = walletReducer(wallet, { type: "debited", amount: 299 });
wallet = walletReducer(wallet, { type: "debited", amount: 5000 });

console.log("Balance:", wallet.balance);
console.log("History:", wallet.history.join(", "));
console.log("Error:", wallet.error);
Output
Balance: 1201
History: +1000, -299
Error: Insufficient balance

In a real project you would write these checks as automated tests, covered in Testing with Vitest. Notice that the failed debit is not an exception: it returns a new state with an error message the UI can show.

useState or useReducer?

Both hooks store state; useState is in fact built on the same mechanism. Choose based on how the updates look:

SituationBetter choice
A few independent values (a toggle, an input)useState
Many related values updated togetheruseReducer
Many different ways to update the same datauseReducer
Next state depends on complex rules you want to testuseReducer
You want event handlers to stay tinyuseReducer

You can mix both in one component. Converting from one to the other later is straightforward, so do not worry about choosing perfectly at the start.

Advanced: reducer plus context

For app-wide state, such as a cart used on many pages, combine a reducer with Context. Put the state in one context and dispatch in another, so any component can read the cart or send actions without prop drilling:

React JSX
const CartContext = createContext(null);
const CartDispatchContext = createContext(null);

function CartProvider({ children }) {
  const [cart, dispatch] = useReducer(cartReducer, [], loadSavedCart);
  return (
    <CartContext value={cart}>
      <CartDispatchContext value={dispatch}>{children}</CartDispatchContext>
    </CartContext>
  );
}

// Anywhere below: const dispatch = useContext(CartDispatchContext);
// dispatch({ type: "added", product });

The third argument, loadSavedCart, is an optional init function: React calls loadSavedCart([]) once to build the initial state, which is useful for reading saved data. dispatch never changes between renders, so components that only send actions do not re-render when the cart changes.

Common mistakes

  • Mutating state in the reducer. state.items.push(x); return state; returns the same object, so nothing re-renders. Return new objects and arrays.
  • Side effects in the reducer. Fetching, logging to a server or Math.random() make the reducer impure. Do them in event handlers or effects, then dispatch the result.
  • Forgetting to return. A case without return falls through to the next case.
  • Reading state right after dispatch. Like a setter, dispatch schedules an update; the variable keeps its old value in the current render.

Key points

  • useReducer(reducer, initialState) returns [state, dispatch].
  • A reducer (state, action) => newState is pure and holds all the update logic in one place.
  • Event handlers dispatch actions such as { type: "added", id } that describe what happened.
  • Reducers are plain functions, so they are easy to test without React.
  • Prefer useReducer when many related updates or complex rules act on the same state.
  • Put state and dispatch in context to share a reducer across the app.

Exercise

Write a scoreReducer for a cricket scoreboard with state { runs, wickets, balls }. Support the actions "run" (with runs: 0 to 6), "wicket" and "reset". Every action except reset adds one ball, and no action may change the score once 10 wickets have fallen. Use it with useReducer and show the score as "Runs/Wickets (Overs)", where overs are written like 2.3 for 15 balls.

Show solution
React JSX
import { useReducer } from "react";
import { createRoot } from "react-dom/client";

const start = { runs: 0, wickets: 0, balls: 0 };

function scoreReducer(score, action) {
  if (action.type === "reset") return start;
  if (score.wickets === 10) return score; // innings over
  switch (action.type) {
    case "run":
      return { ...score, runs: score.runs + action.runs, balls: score.balls + 1 };
    case "wicket":
      return { ...score, wickets: score.wickets + 1, balls: score.balls + 1 };
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function Scoreboard() {
  const [score, dispatch] = useReducer(scoreReducer, start);
  const overs = `${Math.floor(score.balls / 6)}.${score.balls % 6}`;

  return (
    <div>
      <h3>{score.runs}/{score.wickets} ({overs})</h3>
      {[0, 1, 2, 4, 6].map((r) => (
        <button key={r} onClick={() => dispatch({ type: "run", runs: r })}>{r}</button>
      ))}
      <button onClick={() => dispatch({ type: "wicket" })}>Wicket</button>
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<Scoreboard />);

All the cricket rules live in the reducer, so the buttons only describe what happened. Returning the same score object after 10 wickets means React skips the re-render.

Quick Quiz

1. What does a reducer function receive?

2. What should an event handler do when using useReducer?

3. Which of these is allowed inside a reducer?

4. When is useReducer usually a better choice than useState?

Learn React with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call