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.
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.
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.
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.
Try it yourself
- 1
Find the bug from the log
In the logger example, change the
decreasedcase toqty: state.qty - 1(noMath.max). Predict the log line for the seconddecreased. (qtybecomes0.) The log shows exactly which action broke the rule. - 2
Make a test fail
In the test example, change the
toggledcase toreturn 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
Break Immer's rule
In the Immer example, add
return draft;afterlikes++inside thelikedcase. Does it still work? (Yes: returning the draft itself is allowed.) Now return{ ...draft }instead. Run it and read the error.
Code & diagrams
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
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"}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
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// 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)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
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// 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];.
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.
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
useReducerin 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
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
useStatefor the text box. The question is only which one makes this piece of state easier to read and change correctly. - 2
Code size. A reducer needs more code up front: action objects, a
switch, adefault. For one or two simple updates (setOpen(!open)),useStateis 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 onedispatchline. - 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 setsstatusto"paid"), a single reducer case keeps them consistent, which helps avoid the contradictions from Topic 6.1. - 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 oneconsole.loginside 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
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
useStatehandlers means rendering the component and simulating clicks (Topic 17.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). UseuseImmerReducerfrom theuse-immerpackage, or wrap your reducer with Immer'sproduce. Redux Toolkit uses Immer the same way insidecreateSlice(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
Compare useState and useReducer on code size, readability, debugging and testing.
When would you switch a component from useState to useReducer?
How do you test a reducer, and why is it easier than testing a component?
What is Immer, how does it work with reducers, and what are its two main rules?
Practice
Write a withLogger(reducer) wrapper that logs action.type and the next state, and use it with any reducer.
Write two tests (plain functions or Vitest) for a counterReducer with incremented and reset: one per action.
Trade-offs
- ↔
useStateis direct and short;useReduceradds structure that pays off as update logic grows. Start withuseStateand 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
useStateanduseReduceron 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
produceoruseImmerReducerand follow its two rules.