Skip to content
elephantoo

useReducer

Lesson 14 of 30 16 min read

Move complex state logic into a reducer with actions, and combine it with context.


As components grow, state updates can get scattered across many event handlers: add an item here, remove it there, toggle, edit, reset, undo… useReducer lets you consolidate all of that logic in one function, outside your component, where it's easy to read and test. It's the same pattern Redux popularised, built into React.

From useState to useReducer#

Here's a task list with several handlers, each updating state slightly differently:

JSX
function TaskApp() {
  const [tasks, setTasks] = useState(initialTasks);

  function handleAdd(text) {
    setTasks([...tasks, { id: nextId++, text, done: false }]);
  }
  function handleToggle(id) {
    setTasks(tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
  }
  function handleDelete(id) {
    setTasks(tasks.filter((t) => t.id !== id));
  }
  // ...
}

With a reducer, handlers just describe what happened by dispatching an action, and one reducer function decides how the state changes:

JSX
import { useReducer, useState } from "react";

const initialTasks = [
  { id: 1, text: "Learn useState", done: true },
  { id: 2, text: "Learn useReducer", done: false },
];

function tasksReducer(tasks, action) {
  switch (action.type) {
    case "added":
      return [...tasks, { id: action.id, text: action.text, done: false }];
    case "toggled":
      return tasks.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
    case "edited":
      return tasks.map((t) => (t.id === action.id ? { ...t, text: action.text } : t));
    case "deleted":
      return tasks.filter((t) => t.id !== action.id);
    case "cleared_done":
      return tasks.filter((t) => !t.done);
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

export default function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
  const [text, setText] = useState("");

  function handleSubmit(e) {
    e.preventDefault();
    if (!text.trim()) return;
    dispatch({ type: "added", id: crypto.randomUUID(), text: text.trim() });
    setText("");
  }

  return (
    <section>
      <form onSubmit={handleSubmit}>
        <input value={text} onChange={(e) => setText(e.target.value)} placeholder="New task" />
        <button>Add</button>
      </form>
      <ul>
        {tasks.map((t) => (
          <li key={t.id}>
            <input type="checkbox" checked={t.done} onChange={() => dispatch({ type: "toggled", id: t.id })} />
            {t.text}
            <button onClick={() => dispatch({ type: "deleted", id: t.id })}>Delete</button>
          </li>
        ))}
      </ul>
      <button onClick={() => dispatch({ type: "cleared_done" })}>Clear completed</button>
    </section>
  );
}

How it works#

JSX
const [state, dispatch] = useReducer(reducer, initialState);
  • reducer(state, action) — a pure function you write that returns the next state.
  • initialState — the starting value (optionally computed lazily with a third init argument).
  • state — the current state for this render.
  • dispatch(action) — call it to request an update. React runs your reducer with the current state and the action, then re-renders with the result.

An action is a plain object describing what happened. By convention it has a type string, plus any data the reducer needs. Name actions after what happened ("added", "toggled") rather than how to change state ("setTasks").

Reducers must be pure#

A reducer runs during rendering, so it must be pure:

  • No mutation. Return new objects and arrays (...spread, map, filter), never state.push(...).
  • No side effects. No fetch, timers, localStorage writes or random values. Generate ids or timestamps in the handler and put them in the action (as crypto.randomUUID() above).
  • Same input → same output. In Strict Mode, React calls reducers twice in development to help you spot impurities.

Because reducers are just functions, they're easy to unit test without rendering anything:

JavaScript
// using tasksReducer from above
const before = [{ id: 1, text: "A", done: false }];
const after = tasksReducer(before, { type: "toggled", id: 1 });
console.log(after[0].done);  // true
console.log(before[0].done); // false – original untouched

Reducers for object state#

Reducers aren't only for lists. Here's a checkout form with several interdependent fields:

JSX
import { useReducer } from "react";

const initialState = { step: 1, quantity: 1, coupon: "", discount: 0, error: null };

function checkoutReducer(state, action) {
  switch (action.type) {
    case "quantity_changed": {
      const quantity = Math.max(1, Math.min(10, action.quantity));
      return { ...state, quantity };
    }
    case "coupon_typed":
      return { ...state, coupon: action.coupon.toUpperCase(), error: null };
    case "coupon_applied":
      return state.coupon === "SAVE10"
        ? { ...state, discount: 0.1, error: null }
        : { ...state, discount: 0, error: "Invalid coupon" };
    case "next_step":
      return { ...state, step: Math.min(state.step + 1, 3) };
    case "previous_step":
      return { ...state, step: Math.max(state.step - 1, 1) };
    case "reset":
      return initialState;
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

export default function Checkout({ price = 499 }) {
  const [state, dispatch] = useReducer(checkoutReducer, initialState);
  const total = Math.round(price * state.quantity * (1 - state.discount)); // derived

  return (
    <div>
      <p>Step {state.step} of 3</p>
      <label>
        Quantity{" "}
        <input
          type="number"
          value={state.quantity}
          onChange={(e) => dispatch({ type: "quantity_changed", quantity: Number(e.target.value) })}
        />
      </label>
      <label>
        Coupon <input value={state.coupon} onChange={(e) => dispatch({ type: "coupon_typed", coupon: e.target.value })} />
      </label>
      <button onClick={() => dispatch({ type: "coupon_applied" })}>Apply</button>
      {state.error && <p role="alert">{state.error}</p>}
      <p>Total: ₹{total}</p>
      <button onClick={() => dispatch({ type: "previous_step" })} disabled={state.step === 1}>
        Back
      </button>
      <button onClick={() => dispatch({ type: "next_step" })} disabled={state.step === 3}>
        Next
      </button>
      <button onClick={() => dispatch({ type: "reset" })}>Start over</button>
    </div>
  );
}

All the rules — clamping quantity, validating coupons, step boundaries — live in one place.

Lazy initialisation#

Pass an init function as the third argument to compute the initial state once:

JSX
function init(storageKey) {
  try {
    return JSON.parse(localStorage.getItem(storageKey)) ?? [];
  } catch {
    return [];
  }
}

function NotesApp() {
  const [notes, dispatch] = useReducer(notesReducer, "notes", init); // init("notes") runs once
  // ...
}

Reducer + context: app-wide state without a library#

Combine a reducer with context to let any component read state and dispatch actions — a lightweight alternative to Redux:

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

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

function cartReducer(items, action) {
  switch (action.type) {
    case "added": {
      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 "removed":
      return items.filter((i) => i.id !== action.id);
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

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

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

function AddToCart({ product }) {
  const dispatch = useCartDispatch(); // dispatch never changes – this component won't re-render on cart updates
  return <button onClick={() => dispatch({ type: "added", product })}>Add {product.name}</button>;
}

function CartSummary() {
  const items = useCart();
  const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
  return (
    <p>
      {items.length} products · ₹{total}
    </p>
  );
}

dispatch is guaranteed to be stable (the same function every render), which is why it's safe to put in its own context and pass anywhere.

useState or useReducer?#

Prefer useState when…Prefer useReducer when…
State is a few independent valuesMany values change together
Updates are simple (setOpen(true))Many different kinds of updates
Logic fits in one or two handlersLogic is spread across handlers or components
You want to unit-test update logic or log every change

You can mix both in one component, and converting between them is easy. Don't force a reducer onto simple state.

Common mistakes#

  • Mutating state in the reducer (state.items.push(x); return state;) — React may not re-render.
  • Putting side effects (fetching, random ids) inside the reducer.
  • Forgetting to return state for unhandled actions — throwing on unknown types catches typos early.
  • Reading state right after dispatch and expecting the new value (it's a snapshot, just like useState).

What's next#

You've written several reusable chunks of logic already. Next, learn to package them as custom hooks.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What does a reducer function receive and return?

  2. 2.Which is allowed inside a reducer?

  3. 3.When is useReducer a better choice than useState?

Finished reading?

Mark this lesson complete to track your progress.