Skip to main content

πŸŽ›οΈ useReducer for Complex State

When a component grows a tangle of interrelated useState calls, updates become scattered and hard to trust. useReducer pulls all that logic into one pure function, replacing "set this, set that" with clear, named actions β€” the same idea that powers Redux, built right into React.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Decide when useReducer is a better fit than useState
  • Write a pure reducer function and dispatch actions to update state
  • Model multi-property state (items, totals, status, errors) with predictable transitions
  • Use lazy initialization and action creators to keep code clean
  • Combine useReducer with Context for lightweight app-wide state, and unit-test reducers in isolation

Estimated Time: 40–50 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a shopping cart reducer that adds, removes, updates quantities, and recomputes the total.

In This Lesson

Why Complex State Hurts

useState is perfect for a handful of independent values. But as a component accumulates related pieces of state β€” and updates that touch several of them at once β€” you start to see problems:

  • Update logic is scattered across many event handlers.
  • Related fields drift out of sync (you update items but forget total).
  • It's hard to see, in one place, all the ways state can change.
  • Testing means rendering the whole component.
πŸ’‘ A useful analogy: With useState you're flipping individual switches and turning dials by hand. With useReducer you issue commands to a control panel β€” "CHECKOUT", "ADD_ITEM" β€” and a single rulebook (the reducer) decides exactly which switches and dials move. The rulebook lives in one place, so behavior is predictable and easy to audit.
The dispatch cycle A component dispatches an action, the reducer computes the next state from the current state and action, and React re-renders with the new state. Component dispatch(action) Reducer (state, action) New state returned Re- render
Figure 1 β€” You never mutate state directly. You dispatch an action; the pure reducer returns the next state; React re-renders. Every change flows through one place.

Anatomy of useReducer

const [state, dispatch] = useReducer(reducer, initialState, init?);

πŸ“– The four pieces

state β€” the current state value React hands you each render.

dispatch β€” a stable function you call with an action to request a change.

reducer β€” a pure function (state, action) => newState holding all update logic.

init β€” optional lazy initializer for expensive starting state.

The reducer must be pure

A reducer takes the current state and an action, and returns the next state. It must:

  • Be a pure function β€” same inputs always give the same output.
  • Never mutate the existing state; always return a new object.
  • Have no side effects β€” no fetches, no timers, no logging to a server.

Actions describe what happened

An action is a plain object. By convention it has a type string and any extra data (often a payload):

{ type: 'increment' }
{ type: 'addTodo', payload: { id: 1, text: 'Learn useReducer' } }

Here is the whole loop in miniature β€” a counter:

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 'reset':     return { count: 0 };
    default:          throw new Error(`Unknown action: ${action.type}`);
  }
}

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

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'decrement' })}>–</button>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  );
}

Throwing on an unknown action (rather than silently returning state) catches typos early during development.

useState vs useReducer

Neither hook is "better" β€” they suit different shapes of state. This table and decision guide will help you choose.

ConsiderationuseStateuseReducer
State shapeFew independent valuesComplex / interrelated object
Update logicInline in handlersCentralized in the reducer
How you change itCall setters directlyDispatch named actions
PredictabilityAd hocStructured & explicit
TestingVia the componentReducer tested in isolation
Learning curveLowerHigher
flowchart TD A{Is state a complex<br/>object with several fields?} -->|Yes| U[Use useReducer] A -->|No| B{Do updates depend on<br/>the previous state?} B -->|Yes| C{Many different<br/>kinds of update?} B -->|No| S[Prefer useState] C -->|Yes| U C -->|No| D{Need to reuse or<br/>test the logic alone?} D -->|Yes| U D -->|No| S

πŸ’‘ Don't over-engineer

For the simple counter above, useState is actually more concise. useReducer pays off when the state object has several fields and there are many distinct, related ways to change it β€” as in the cart below.

Worked Example: Shopping Cart

A cart is the textbook case: multiple items, a derived total, a checkout status, and an error slot β€” all changing together through several action types.

import { useReducer } from 'react';

const initialState = { items: [], total: 0, isCheckingOut: false, error: null };

// Recompute the total from the items β€” a single source of truth
const sumTotal = (items) =>
  items.reduce((sum, item) => sum + item.price * item.quantity, 0);

function cartReducer(state, action) {
  switch (action.type) {
    case 'add': {
      const product = action.payload;
      const existing = state.items.find((i) => i.id === product.id);
      const items = existing
        ? state.items.map((i) =>
            i.id === product.id ? { ...i, quantity: i.quantity + 1 } : i
          )
        : [...state.items, { ...product, quantity: 1 }];
      return { ...state, items, total: sumTotal(items), error: null };
    }

    case 'remove': {
      const items = state.items.filter((i) => i.id !== action.payload);
      return { ...state, items, total: sumTotal(items) };
    }

    case 'setQuantity': {
      const { id, quantity } = action.payload;
      if (quantity < 1) return { ...state, error: 'Quantity must be at least 1' };
      const items = state.items.map((i) =>
        i.id === id ? { ...i, quantity } : i
      );
      return { ...state, items, total: sumTotal(items), error: null };
    }

    case 'checkoutStart':   return { ...state, isCheckingOut: true, error: null };
    case 'checkoutSuccess': return initialState;                 // empty cart
    case 'checkoutError':   return { ...state, isCheckingOut: false, error: action.payload };
    case 'clear':           return initialState;
    default:                throw new Error(`Unknown action: ${action.type}`);
  }
}

function ShoppingCart() {
  const [state, dispatch] = useReducer(cartReducer, initialState);
  const { items, total, isCheckingOut, error } = state;

  const products = [
    { id: 1, name: 'Notebook', price: 4.5 },
    { id: 2, name: 'Pen set', price: 9.0 },
  ];

  async function checkout() {
    dispatch({ type: 'checkoutStart' });
    try {
      await new Promise((r) => setTimeout(r, 800)); // pretend API call
      dispatch({ type: 'checkoutSuccess' });
    } catch (err) {
      dispatch({ type: 'checkoutError', payload: err.message });
    }
  }

  return (
    <div>
      {error && <p role="alert">{error}</p>}

      <ul>
        {products.map((p) => (
          <li key={p.id}>
            {p.name} β€” ${p.price.toFixed(2)}
            <button onClick={() => dispatch({ type: 'add', payload: p })}>Add</button>
          </li>
        ))}
      </ul>

      <h3>Cart</h3>
      {items.length === 0 ? (
        <p>Your cart is empty.</p>
      ) : (
        <ul>
          {items.map((i) => (
            <li key={i.id}>
              {i.name} Γ—
              <input
                type="number"
                min="1"
                value={i.quantity}
                onChange={(e) =>
                  dispatch({
                    type: 'setQuantity',
                    payload: { id: i.id, quantity: Number(e.target.value) },
                  })
                }
              />
              = ${(i.price * i.quantity).toFixed(2)}
              <button onClick={() => dispatch({ type: 'remove', payload: i.id })}>
                Remove
              </button>
            </li>
          ))}
        </ul>
      )}

      <p><strong>Total: ${total.toFixed(2)}</strong></p>
      <button onClick={checkout} disabled={items.length === 0 || isCheckingOut}>
        {isCheckingOut ? 'Processing…' : 'Checkout'}
      </button>
    </div>
  );
}

βœ… Notice what the reducer buys us

  • total is always correct β€” it's recomputed from items in one place, never updated by hand.
  • Every way the cart can change is visible in one switch statement.
  • Async work (checkout) stays in the component; the reducer only records the result via actions.

Lazy Init, Actions & Context

Lazy initialization

If computing the initial state is expensive (parsing localStorage, heavy math), pass an init function as the third argument. React calls it only on the first render:

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

function Counter({ startCount }) {
  // `init` runs once, receiving the second argument
  const [state, dispatch] = useReducer(reducer, startCount, init);
  // …
}

Action creators

Small helper functions that build actions keep components tidy and centralize action shape:

const addTodo = (text) => ({
  type: 'addTodo',
  payload: { id: crypto.randomUUID(), text, done: false },
});

// In the component:
dispatch(addTodo('Buy milk'));

Immer for deep updates

Deeply nested state makes spread-based updates verbose. Immer lets you write "mutating" code that stays immutable under the hood:

import { produce } from 'immer';

function reducer(state, action) {
  return produce(state, (draft) => {
    switch (action.type) {
      case 'renameUser':
        draft.user.profile.name = action.payload; // safe "mutation"
        break;
      case 'toggleTheme':
        draft.prefs.theme = draft.prefs.theme === 'dark' ? 'light' : 'dark';
        break;
    }
  });
}

useReducer + Context = lightweight global state

Combine a reducer with Context to share state across a subtree without prop drilling β€” a Redux-like store with zero dependencies:

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

const CartContext = createContext(null);

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

// Custom hook so consumers never touch the context directly
export function useCart() {
  const ctx = useContext(CartContext);
  if (!ctx) throw new Error('useCart must be used within a CartProvider');
  return ctx;
}

πŸ’‘ When to graduate to a library

useReducer + Context is ideal for moderate shared state. For large apps with frequent updates across many components, a dedicated store like Zustand or Redux Toolkit adds performance optimizations and devtools. The reducer concept you just learned transfers directly.

Testing Reducers

Because a reducer is a pure function, testing it needs no React, no DOM, and no mocks β€” just inputs and expected outputs. This is one of the biggest practical wins of the pattern.

import { describe, test, expect } from 'vitest';
import { cartReducer, initialState } from './cartReducer';

describe('cartReducer', () => {
  test('adds a new item with quantity 1', () => {
    const next = cartReducer(initialState, {
      type: 'add',
      payload: { id: 1, name: 'Pen', price: 2 },
    });
    expect(next.items).toHaveLength(1);
    expect(next.total).toBe(2);
  });

  test('increments quantity for an existing item', () => {
    const withOne = cartReducer(initialState, {
      type: 'add',
      payload: { id: 1, name: 'Pen', price: 2 },
    });
    const withTwo = cartReducer(withOne, {
      type: 'add',
      payload: { id: 1, name: 'Pen', price: 2 },
    });
    expect(withTwo.items[0].quantity).toBe(2);
    expect(withTwo.total).toBe(4);
  });

  test('rejects a quantity below 1', () => {
    const withOne = cartReducer(initialState, {
      type: 'add',
      payload: { id: 1, name: 'Pen', price: 2 },
    });
    const next = cartReducer(withOne, {
      type: 'setQuantity',
      payload: { id: 1, quantity: 0 },
    });
    expect(next.error).toMatch(/at least 1/);
  });
});

These tests run in milliseconds and document exactly how each action behaves β€” the reducer becomes living specification.

Hands-on Exercise

πŸ‹οΈ Build a to-do reducer

Objective: Manage a to-do list entirely through a reducer.

Requirements:

  1. add β€” append a new todo { id, text, done: false }.
  2. toggle β€” flip the done flag for a given id.
  3. delete β€” remove a todo by id.
  4. clearCompleted β€” drop every completed todo at once.
  5. Write one Vitest/Jest test proving toggle works.
πŸ’‘ Hint

Use map for toggle (return a new object only for the matching id), filter for delete and clearCompleted. Never mutate β€” always return fresh arrays and objects.

βœ… Solution
export const initialTodos = { items: [] };

export function todoReducer(state, action) {
  switch (action.type) {
    case 'add':
      return {
        items: [...state.items, { id: crypto.randomUUID(), text: action.payload, done: false }],
      };
    case 'toggle':
      return {
        items: state.items.map((t) =>
          t.id === action.payload ? { ...t, done: !t.done } : t
        ),
      };
    case 'delete':
      return { items: state.items.filter((t) => t.id !== action.payload) };
    case 'clearCompleted':
      return { items: state.items.filter((t) => !t.done) };
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

// --- test ---
import { test, expect } from 'vitest';

test('toggle flips done', () => {
  const seeded = todoReducer(initialTodos, { type: 'add', payload: 'Ship it' });
  const id = seeded.items[0].id;
  const toggled = todoReducer(seeded, { type: 'toggle', payload: id });
  expect(toggled.items[0].done).toBe(true);
});

Summary & Quiz

πŸŽ‰ Key Takeaways

  • useReducer centralizes complex state logic in one pure reducer function.
  • You dispatch actions ({ type, payload }) instead of calling many setters.
  • Reducers must be pure β€” no mutation, no side effects; return a new state object.
  • Reach for it when state is a complex object with many related update paths; keep useState for simple values.
  • Pair with Context for shared state, and enjoy trivial unit testing of the reducer.

🎯 Quick Quiz

Question 1: Which statement about a reducer function is correct?

Question 2: When is useReducer clearly preferable to useState?

Question 3: How should the shopping-cart total be kept correct?

πŸ“š Further Reading

πŸš€ What's Next?

You've now used useState, useEffect, and useReducer. The next step is packaging your own reusable logic: custom hooks let you extract these patterns into shareable functions like useFetch and useLocalStorage.

πŸŽ‰ Complex state, tamed!

One reducer to rule them all. Let's make logic reusable with custom hooks.