Command Palette

Search for a command to run...

Hectal
PHASE 9Intermediate ~28 min· topic 5 of 6

Topic 9.5

useState or useReducer?

In one line

Both hold the same kind of local state; choose useState for simple, independent values and useReducer when many handlers update the same state in related ways, when you want to read and debug every change as an action, or test the logic as a plain function, and add Immer when the updates are deeply nested.

Think of it like this

Cooking at home versus running a restaurant kitchen. At home you add salt when you feel like it, straight into the pot: quick and simple. A restaurant writes every order on a ticket and the chef follows set recipes, so anyone can see what was ordered, repeat a dish exactly and check what went wrong. useState is home cooking; useReducer is the ticket system. Neither is better; it depends on how busy the kitchen is.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Boilerplate
Code you have to write for structure (action objects, a switch) rather than for the feature itself.
Indirection
An extra step between cause and effect: the handler dispatches, the reducer decides.
Logger
A wrapper that prints each action and the state before and after it, for debugging.
Unit test
A small automatic check that calls one function with known input and compares the output.
Vitest
A popular test runner for Vite projects. Runs test files and reports passes and failures.
Immer
A library that lets you write "mutating" code on a draft and produces a new immutable state.
Draft
Immer's stand-in copy of your state that you may change freely inside the producer.
Structural sharing
A new state that reuses every unchanged part of the old one, so only changed branches are new objects.
Frozen object
An object made read-only with Object.freeze; trying to change it fails (and throws in strict code).

Step by step

01Count the handlers that touch the state

One or two simple setters: keep useState. Four handlers all rebuilding the same list or object: a reducer will likely be shorter and easier to check.

02Look for changes that must happen together

If one event updates several state variables at once (setItems([]), setCoupon(null), setStatus("paid")), forgetting one creates a contradiction. One reducer case makes them a single step.

checkoutReducer.jswhole filejsx
case "checkedOut":
  return { ...state, items: [], coupon: null, status: "paid" };

03Add a logger while debugging

Because every change flows through the reducer, a wrapper can log them all. Remove it (or keep it behind a development check) when you're done.

withLogger.jswhole filejsx
export function withLogger(reducer) {
  return (state, action) => {
    const next = reducer(state, action);
    console.log(action.type, state, "->", next);
    return next;
  };
}

// const [state, dispatch] = useReducer(withLogger(cartReducer), initialCart);

04Test the reducer without React

A reducer test is input, action, expected output. No component, no DOM.

tasksReducer.test.jswhole filejsx
import { test, expect } from "vitest";
import { tasksReducer } from "./tasksReducer";

test("adds a task", () => {
  const next = tasksReducer([], { type: "added", id: 1, text: "Tea" });
  expect(next).toEqual([{ id: 1, text: "Tea", done: false }]);
});

05Reach for Immer when nesting hurts

Write the case as a mutation of the draft; Immer produces the new state and shares every unchanged branch with the old one.

Reach for Immer when nesting hurtsdiagram
Rendering diagram…

Try it yourself

  1. 1

    Find the bug from the log

    In the logger example, change the decreased case to qty: state.qty - 1 (no Math.max). Predict the log line for the second decreased. (qty becomes 0.) The log shows exactly which action broke the rule.

  2. 2

    Make a test fail

    In the test example, change the toggled case to return tasks.map((t) => ({ ...t, done: !t.done })) (it toggles everything). Which test fails, and what does its message say? Fix it and watch it go green again.

  3. 3

    Break Immer's rule

    In the Immer example, add return draft; after likes++ inside the liked case. Does it still work? (Yes: returning the draft itself is allowed.) Now return { ...draft } instead. Run it and read the error.

Code & diagrams

Debugging: every change passes through one place New tab

The console is a full history: each action with the state before and after. The second `decreased` changes nothing (the minimum is 1), and you can see that at a glance. `checkedOut` changes three fields in one step.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

increased: {"qty":1,"coupon":null,"status":"open"} -> {"qty":2,"coupon":null,"status":"open"}
decreased: {"qty":2,"coupon":null,"status":"open"} -> {"qty":1,"coupon":null,"status":"open"}
decreased: {"qty":1,"coupon":null,"status":"open"} -> {"qty":1,"coupon":null,"status":"open"}
couponApplied: {"qty":1,"coupon":null,"status":"open"} -> {"qty":1,"coupon":"CHAI10","status":"open"}
checkedOut: {"qty":1,"coupon":"CHAI10","status":"open"} -> {"qty":1,"coupon":null,"status":"paid"}
Testing a reducer as a plain function New tab

No component is rendered to run these tests: the reducer is just a function. Freezing the input is a cheap way to prove a reducer never mutates (ES modules run in strict mode, so changing a frozen object throws).

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

✓ adds a task
✓ toggles only the matching task
✓ never mutates the old state
✓ throws on an unknown action
The same tests in Vitest (a real project), with the terminal outputjsx
// src/tasksReducer.test.js
import { describe, test, expect } from "vitest";
import { tasksReducer } from "./tasksReducer";

describe("tasksReducer", () => {
  test("adds a task", () => {
    expect(tasksReducer([], { type: "added", id: 1, text: "Tea" }))
      .toEqual([{ id: 1, text: "Tea", done: false }]);
  });

  test("throws on an unknown action", () => {
    expect(() => tasksReducer([], { type: "renamed" })).toThrow("Unknown action: renamed");
  });
});

// Terminal:
// $ npx vitest run
//
//  ✓ src/tasksReducer.test.js (2 tests) 2ms
//
//  Test Files  1 passed (1)
//       Tests  2 passed (2)
Immer inside a reducer: write mutations, get immutable state New tab

The `liked` case is two short lines instead of three levels of spread and `map`. The old state is untouched, the changed branch is new, and the unchanged playlist is the very same object, so a `memo` row for it could skip rendering.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

old likes: 0 new likes: 1
Road trip copied: true
Study shared, not copied: true
result is frozen: true
The same feature both ways, and useImmerReducerjsx
// useState: the "how" lives in every handler.
const [tasks, setTasks] = useState(initialTasks);
const add = (text) => setTasks([...tasks, { id: nextId++, text, done: false }]);
const toggle = (id) => setTasks(tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
const remove = (id) => setTasks(tasks.filter((t) => t.id !== id));

// useReducer: handlers say what happened; tasksReducer (tested on its own) decides.
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
const add = (text) => dispatch({ type: "added", id: nextId++, text });
const toggle = (id) => dispatch({ type: "toggled", id });
const remove = (id) => dispatch({ type: "deleted", id });

// use-immer (npm install immer use-immer): the reducer receives a draft.
import { useImmerReducer } from "use-immer";

function tasksDraftReducer(draft, action) {
  switch (action.type) {
    case "toggled": {
      const task = draft.find((t) => t.id === action.id);
      task.done = !task.done;
      break;
    }
    // ...
  }
}
const [tasks, dispatch] = useImmerReducer(tasksDraftReducer, initialTasks);

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Mutating the draft and returning a new value

In an Immer reducer, write a case that does draft.push(action.text); return [...draft];.

terminal
$ Console
── what you'll see ──
Error: [Immer] An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft.

Break #2

Changing state that Immer froze

In the Immer example, add a button whose handler does state.playlists.push({ id: 3, name: "Gym", songs: [] }). Like a song first (so state came from Immer), then click it.

terminal
$ Console
── what you'll see ──
Uncaught TypeError: Cannot add property 2, object is not extensible

Myth vs fact

Myth

Reducers are always better because they're more "professional".

Fact

For a toggle or a text box, a reducer is just extra code. Pick per piece of state; mixing both in one component is normal.

Myth

You need Redux to test state logic.

Fact

Any reducer is a pure function and can be tested with plain calls, no store or React needed.

Myth

Immer mutates your state.

Fact

Immer gives you a draft, records your changes and returns a new object. The original stays the same, and unchanged parts are shared.

Myth

useReducer re-renders less than useState.

Fact

They schedule renders the same way. A reducer helps performance only indirectly, because dispatch is stable and easy to pass down.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    React's docs sum up the choice in five points: code size, readability, debugging, testing and personal preference. In practice the decisive signal is usually several handlers doing related updates to the same state, or one event changing several values.

  • ▸

    Immer drafts are Proxies: reading creates child proxies lazily, writing marks the path as modified and makes shallow copies on write. Only modified objects are copied at the end, which is why structural sharing comes for free. Immer freezes results by default, in development and production; setAutoFreeze(false) turns it off for hot paths.

  • ▸

    Because a reducer is a pure function of (state, action), a list of actions is enough to rebuild any state: replay them from the initial state. That's the idea behind Redux DevTools' time travel and behind event sourcing on the backend (see /system-design for event-driven systems).

  • ▸

    A reducer can be shared: the same pure function can drive useReducer in a component, a Redux store, a server-side validation of a submitted sequence of actions, or tests. Keep reducers in their own module with no React import.

Remember this

  1. 1

    They do the same job. Both keep local component state, both trigger a re-render, both treat state as a snapshot, and you can convert one into the other at any time. You can also mix them in one component: a reducer for the task list and a plain useState for the text box. The question is only which one makes this piece of state easier to read and change correctly.

  2. 2

    Code size. A reducer needs more code up front: action objects, a switch, a default. For one or two simple updates (setOpen(!open)), useState is shorter and clearer. When many handlers update the same state in similar ways, a reducer is often shorter overall, because the copying logic is written once per action instead of once per handler, and handlers shrink to one dispatch line.

  3. 3

    Readability. With useState, the "how" of every update sits inside the handlers, mixed with UI code. A reducer separates what happened (the handler: dispatch({ type: "checkedOut" })) from how state changes (the reducer). When one event must change several values together (checking out empties the cart, clears the coupon and sets status to "paid"), a single reducer case keeps them consistent, which helps avoid the contradictions from Topic 6.1.

  4. 4

    Debugging. If state goes wrong with useState, any of the setters might be the cause. With a reducer, every change passes through one function, so one console.log inside it (or a small logger wrapper) shows each action with the state before and after: a full history of what happened. The cost is one more level of indirection when you step through code.

  5. 5

    Testing. A reducer is a pure function that doesn't depend on React, so you can test it like any function: call it with a state and an action and compare the result. No rendering, no clicking, no waiting. In a real project that's a few lines of Vitest (Topic 17.2). Testing the same logic inside useState handlers means rendering the component and simulating clicks (Topic 17.6).

  6. 6

    Immer with reducers. Reducers for deeply nested state get long: { ...state, playlists: state.playlists.map(...{ ...p, songs: p.songs.map(...) }) }. Immer (met in Topic 4.6) lets you write the reducer as if you were mutating a draft: draft.playlists[0].songs[0].likes++. Immer records the changes and returns a new immutable state, copying only what changed (this is called structural sharing). Use useImmerReducer from the use-immer package, or wrap your reducer with Immer's produce. Redux Toolkit uses Immer the same way inside createSlice (Topic 15.4). Two Immer rules: either mutate the draft or return a new value, never both; and the result is frozen, so accidental later mutations throw.

Explain it without notes

01

Compare useState and useReducer on code size, readability, debugging and testing.

02

When would you switch a component from useState to useReducer?

03

How do you test a reducer, and why is it easier than testing a component?

04

What is Immer, how does it work with reducers, and what are its two main rules?

Practice

01

Write a withLogger(reducer) wrapper that logs action.type and the next state, and use it with any reducer.

02

Write two tests (plain functions or Vitest) for a counterReducer with incremented and reset: one per action.

Trade-offs

  • ↔

    useState is direct and short; useReducer adds structure that pays off as update logic grows. Start with useState and switch when handlers start repeating each other.

  • ↔

    Immer makes nested updates short and safe but adds a dependency (a few KB) and a little runtime cost; flattening the state (Topic 6.1) is the dependency-free alternative.

Done when you can

  • I can explain the difference between useState and useReducer on code size, readability, debugging and testing.

  • I can tell when a component should switch to a reducer.

  • I can add a logger to a reducer to see every action.

  • I can test a reducer as a plain function, including immutability and unknown actions.

  • I can use Immer's produce or useImmerReducer and follow its two rules.