React

useReducer — Complex State Logic in React

Thirdy Gayares
14 min read

🎓 What You Will Learn

  • Why useReducer: when useState stops scaling for related state
  • Reducers & dispatch: (state, action) => newState, and firing actions
  • Actions with payloads: passing data along with an action type
  • useState vs useReducer: a clear decision rule, not a vibe
  • Lazy initialization: computing expensive initial state only once
  • useReducer + useContext: the mini-Redux pattern for global state
1

Why useReducer Exists

useState is great for one independent value. It gets messy once a component has several related pieces of state that change together — a form with loading/error/data, or a cart with items, totals, and discounts. You end up with five useState calls and update logic scattered across multiple handlers.

useReducer centralizes that logic: one function decides how state changes for every possible action, and components just describe what happened — not how the state should be recomputed.

2

Reducer Basics — dispatch and Actions

A reducer is a pure function: (state, action) => newState. useReducer(reducer, initialState) returns the current state and a dispatch function. Calling dispatch(action) runs the reducer and re-renders with whatever it returns.

0

Pure means predictable: given the same state and action, a reducer must always return the same result, with no side effects (no API calls, no Math.random()). That's what makes state changes easy to trace and test.
3

Actions with a Payload

Most real actions carry data along with the type — which todo to toggle, what text to add. Convention: keep type as a string and attach whatever extra fields the reducer needs.

Learn useReducer
Keep the reducer itself dumb. It should only compute the next state from state and action — no API calls inside a case. Trigger side effects in the component (usually inside a useEffect), then dispatch the result.
4

useState vs. useReducer — How to Decide

There's no hard rule, but a good heuristic: if you can describe your state changes as "increment this number" or "toggle this boolean,"useState is simpler. If you find yourself writing "and also update this other field" inside multiple handlers, that's the smell that means useReducer will pay off.

SignalReach for
A single independent value (text, boolean, number)useState
Several values that always change togetheruseReducer
The next state depends on the previous state in a complex wayuseReducer
Many handlers each doing 1–2 setState callsuseReducer
You want state transitions to be traceable/testable in isolationuseReducer
5

Lazy Initialization

If computing the initial state is expensive (parsing localStorage, building a big default object), pass a third argument — an init function — so it only runs once, not on every render.

lazy-init.jsx
function init(initialCount) {
  // Runs ONCE, not on every render
  return { count: initialCount, history: [] };
}

function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1, history: [...state.history, "inc"] };
    default:
      return state;
  }
}

// Third argument is the initial value passed into init()
const [state, dispatch] = useReducer(reducer, 0, init);
6

useReducer + useContext — The Mini-Redux Pattern

Pair a reducer with context and you get a small, dependency-free global store: dispatch is stable across renders (React guarantees it never changes), so any component anywhere in the tree can read state or fire actions without prop drilling.

Products

Keyboard — $49
Mouse — $29

Cart

Cart is empty.

This is exactly Redux's shape, minus the library: one reducer, one source of truth, actions describing intent. It's enough for small-to-medium global state — reach for Redux Toolkit or Zustand once you need middleware, devtools, or many independent stores.
7

Common Mistakes

1Mutating state inside the reducer
mistake-mutate.jsx
// ❌ BAD: mutates the array in place
case "toggle":
  state[index].done = !state[index].done;
  return state;

// ✅ GOOD: returns a new array
case "toggle":
  return state.map((t, i) =>
    i === index ? { ...t, done: !t.done } : t
  );
2Forgetting a default case
mistake-no-default.jsx
// ❌ BAD: an unknown action silently returns undefined
function reducer(state, action) {
  switch (action.type) {
    case "add": return [...state, action.item];
  }
}

// ✅ GOOD: always fall back to the current state
function reducer(state, action) {
  switch (action.type) {
    case "add": return [...state, action.item];
    default: return state;
  }
}
3Side effects inside the reducer
mistake-side-effects.jsx
// ❌ BAD: API calls don't belong in a reducer
case "save":
  fetch("/api/save", { method: "POST", body: JSON.stringify(state) });
  return state;

// ✅ GOOD: dispatch describes intent; effects live in a useEffect
useEffect(() => {
  if (state.shouldSave) {
    fetch("/api/save", { method: "POST", body: JSON.stringify(state) });
  }
}, [state.shouldSave]);
8

Best Practices

Keep reducers pure — no API calls, no randomness, no mutation
Always include a default case that returns the current state
Name actions as events, not setters — "todo/added" beats "setTodos"
Use lazy init for expensive initial state computation
Pair with context only once state genuinely needs to be global
9

Pattern Reference

PiecePurpose
reducer(state, action)Pure function computing the next state
useReducer(reducer, initial)Returns [state, dispatch]
dispatch(action)Triggers the reducer and a re-render
useReducer(reducer, arg, init)Lazy initialization — init(arg) runs once
Context + useReducerA dependency-free global store (mini-Redux)
10

Practice Project

Build a small quiz app reducer that:

  • Tracks currentQuestion, score, and answers in one state object
  • Handles "answer/submitted" (advance the question, update the score) and "quiz/restarted" actions
  • Uses lazy initialization to shuffle the question order once
  • Wraps it in context so a progress bar and the question screen both read from the same reducer
11

What's Next?

🚀 Next Learning Topics:
  • useContext: revisit the Provider pattern this post builds on
  • Custom Hooks: extract a useCart() hook that wraps the reducer + context
  • useRef: persistent values that don't trigger re-renders
  • State Management: when to graduate from this pattern to Zustand or Redux Toolkit

Keep practicing! 💪 Once state stops being "one value" and starts being "a system of related values," reaching for useReducer instead of five useState calls will make your components far easier to reason about.

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.