Command Palette

Search for a command to run...

Hectal
PHASE 9Intermediate React 16.8+ ~28 min· topic 4 of 6

Topic 9.4

useReducer: State Logic in One Place

In one line

useReducer(reducer, initialState) keeps a component's state like useState, but every change goes through one pure function, reducer(state, action), that returns the next state; event handlers only dispatch actions that describe what happened.

Think of it like this

A bank counter. You don't walk into the vault and change your balance yourself. You fill in a slip ("deposit ₹500") and hand it to the cashier. The cashier follows the bank's rules, works out the new balance and writes it in the book. A reducer is the cashier, an action is the slip, and dispatch is handing the slip over. All the rules live with the cashier, not with every customer.

Words you'll meet

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

Reducer
A pure function (state, action) => nextState that holds all the rules for changing a piece of state.
Action
A plain object describing what happened, usually { type: "added", ...data }.
Action type
The string in action.type that tells the reducer which case to run.
dispatch
The function from useReducer that sends an action to the reducer.
useReducer
A hook that returns [state, dispatch] and runs your reducer to work out each new state.
Pure function
A function that returns the same output for the same input and changes nothing outside itself.
Init function
The optional third argument to useReducer, called once to build the initial state.
Lazy initialisation
Building the initial state only on the first render, by passing a function instead of its result.

Step by step

01Start from handlers full of setState

Each handler knows how to rebuild the task list. Three handlers, three different copies of the update rules, and they're mixed in with the UI code.

TaskApp.jsx (before)whole filejsx
function handleAdd(text) {
  setTasks([...tasks, { id: nextId++, text, done: false }]);
}
function handleChange(task) {
  setTasks(tasks.map((t) => (t.id === task.id ? task : t)));
}
function handleDelete(id) {
  setTasks(tasks.filter((t) => t.id !== id));
}

02Make handlers dispatch actions

Each handler now only says what happened. It doesn't know how the list is updated.

TaskApp.jsx (handlers)whole filejsx
function handleAdd(text) {
  dispatch({ type: "added", id: nextId++, text });
}
function handleChange(task) {
  dispatch({ type: "changed", task });
}
function handleDelete(id) {
  dispatch({ type: "deleted", id });
}

03Write the reducer

All the update rules move into one pure function outside the component. A switch picks the case by action.type; each case returns a new array.

tasksReducer.jswhole filejsx
export function tasksReducer(tasks, action) {
  switch (action.type) {
    case "added": {
      return [...tasks, { id: action.id, text: action.text, done: false }];
    }
    case "changed": {
      return tasks.map((t) => (t.id === action.task.id ? action.task : t));
    }
    case "deleted": {
      return tasks.filter((t) => t.id !== action.id);
    }
    default: {
      throw new Error("Unknown action: " + action.type);
    }
  }
}

04Connect it with useReducer

Replace useState with useReducer. You get the state and dispatch back.

TaskApp.jsxwhole filejsx
import { useReducer } from "react";
import { tasksReducer } from "./tasksReducer";

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

05Follow one action through

A click dispatches an action. React queues it, renders the component again, and during that render runs the reducer with the current state. The returned state is what this render sees.

Follow one action throughdiagram
Rendering diagram…

06Initialise lazily when it's expensive

Pass the argument and an init function separately, and React runs the init function only once.

TaskApp.jsxwhole filejsx
// ❌ builds the list on every render, then throws it away
useReducer(tasksReducer, createInitialTasks(username));

// ✅ React calls createInitialTasks(username) on the first render only
useReducer(tasksReducer, username, createInitialTasks);

Try it yourself

  1. 1

    Log right after dispatch

    In the counter, change the + button to onClick={() => { dispatch({ type: "incremented" }); console.log("after dispatch:", state.count); }}. Predict the line after the first click. (after dispatch: 0. state is this render's snapshot; the new value arrives in the next render, exactly like useState.)

  2. 2

    Add a new action

    Give the counter a doubled action and a ×2 button. Which file or function do you change for the rule itself? (Only counterReducer: the button just dispatches { type: "doubled" }.)

  3. 3

    Add 'Clear done' to the task list

    Add a button that removes every finished task. Write the case first (case "clearedDone": return tasks.filter((t) => !t.done);), then the button. Notice you didn't touch any other handler.

Code & diagrams

A counter with a reducer New tab

The script clicks +, +, Step 5, +, Reset. Every button only dispatches an action; how the numbers change is decided in one place, `counterReducer`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

render: count 0 step 1
render: count 1 step 1
render: count 2 step 1
render: count 2 step 5
render: count 7 step 5
render: count 0 step 5
A task list: handlers dispatch, the reducer decides New tab

Add, tick and delete trips. The component has no list-copying code at all; it only describes what the user did. `nextId` is increased in the handler, because a reducer must not have side effects.

Preview

Press Run to see the page.

Console

Nothing logged yet.
Reducers must be pure: StrictMode calls them twice New tab

This example uses StrictMode on purpose. One click each: the impure reducer added Milk twice, because its second (StrictMode) call pushed into the same array again. The pure reducer gives the same answer however often it runs. In production the reducer runs once, so the bug would hide until something else re-runs it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Milk, Milk
Milk
Lazy initialisation: pass the init function, not its result New tab

Both lists start the same, but after the clicks only the eager one runs `createInitialTodos` again (and throws the result away). For three items it doesn't matter; for parsing a big saved document it does.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

createInitialTodos ran for eager
createInitialTodos ran for lazy
--- click both buttons
createInitialTodos ran for eager

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

A typo in the action type

In the counter, change the + button to dispatch { type: "incremnted" } and click it.

terminal
$ Console
── what you'll see ──
render: count 0 step 1
Uncaught Error: Unknown action: incremnted
An error occurred in the <Counter> component.
 
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.

Break #2

Mutating and returning the same state

Build a shopping list whose reducer's add case is items.push(action.text); return items;, log "render:", items.length, "items on screen" in the component, click Add milk twice, then count the <li> elements.

terminal
$ Console
── what you'll see ──
render: 0 items on screen
render: 1 items on screen
render: 2 items on screen
list items in the DOM: 0

Myth vs fact

Myth

useReducer is for big apps; useState is for small ones.

Fact

Both are local component state. useReducer fits whenever update logic is complex or shared between many handlers, even in a small component.

Myth

dispatch runs the reducer immediately and updates state.

Fact

It queues the action. The reducer runs during the next render, and state in the running code keeps this render's value.

Myth

A reducer can fetch data or generate ids, since it's where the logic lives.

Fact

Reducers must be pure. Generate ids and timestamps in the handler and pass them in the action; do fetching in handlers or effects.

Myth

useReducer is the same as Redux.

Fact

It borrows Redux's reducer and action idea, but it's local to one component, has no middleware and no devtools. Combined with context it can serve as a small global store (Topic 9.6).

Pro corner

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

  • ▸

    React's own useState is implemented as useReducer with a built-in reducer, (state, action) => typeof action === "function" ? action(state) : action. That's why setX(prev => ...) updater functions and dispatch queue up and process in the same way, in order, during the next render.

  • ▸

    Older versions could run the reducer eagerly inside dispatch to skip a render when nothing changed. Current React runs it during render, so the reducer always sees the latest props (a reducer declared inside the component can read them, though that's rarely a good idea). The breakIt above shows the consequence: a no-change dispatch can still call your component once before React bails out.

  • ▸

    Under StrictMode, React calls the reducer and the init function twice in development and keeps one result. That catches mutation, as shown above, and is also why a reducer that logs prints twice.

  • ▸

    Reducers are the classic home for state machines: switch on the current status first, then the action, and ignore actions that aren't valid in that status (for example, ignore submitted while already submitting). Libraries like XState formalise that (Topic 15.5).

Remember this

  1. 1

    As a component grows, its event handlers fill up with setTasks(...) calls that each copy and change state in a slightly different way. The update logic is spread across many handlers. A reducer pulls all of it into one function outside the component: function tasksReducer(state, action) { ... return nextState; }. It receives the current state and an action, and returns the next state. The name comes from JavaScript's array.reduce((total, item) => ..., start), which also turns "the result so far plus one more thing" into a new result.

  2. 2

    An action is a plain object that describes what happened, not how to change state: { type: "added", id: 3, text: "Buy milk" }. By convention it has a type string (often in the past tense, like "added", "deleted", "stepChanged") plus whatever extra data the reducer needs. Naming actions after user events ("the user deleted task 3") rather than state edits ("set tasks to…") keeps handlers short and makes the action log read like a story of what the user did.

  3. 3

    Inside the component you write const [state, dispatch] = useReducer(tasksReducer, initialTasks). state is the current value, just like with useState. **dispatch** is a function that sends an action: dispatch({ type: "deleted", id: 2 }). React queues the action, and during the next render calls tasksReducer(currentState, action) and uses the returned value as the new state. Like a state setter, dispatch doesn't change state in the code that's already running: after dispatch(...), the state variable still holds this render's snapshot (Topic 4.4). And dispatch is stable: it's the same function on every render, so it's safe to pass down and to leave out of effect dependencies.

  4. 4

    Inside the reducer, the usual style is a **switch on action.type**, with one case per action and a block { } around each case so its const names don't clash. Each case returns a new state built with the immutable patterns from Topics 4.6 and 4.7 (spread, map, filter). The default case should throw (throw new Error("Unknown action: " + action.type)) so a typo like "incremnted" fails loudly instead of silently doing nothing. You can also write reducers with if statements; the switch is just the common habit.

  5. 5

    Reducers must be pure. A pure function returns the same result for the same inputs and changes nothing outside itself (Topic 2.6). So a reducer must not mutate state or action, must not fetch, set timers, log to a server, or read random values or the current time. React calls the reducer during rendering, and in development StrictMode calls it twice to help you catch impurity. If you mutate and return the same object, React sees no change (Object.is) and throws the render away, so the screen doesn't update. Side effects belong in event handlers or effects; random ids and timestamps should be created in the handler and put into the action.

  6. 6

    Lazy initialisation. useReducer(reducer, createInitialState(user)) calls createInitialState on every render, even though React only uses the result the first time. If that's expensive (parsing a large saved document, building 1,000 items), pass the function and its argument separately: useReducer(reducer, user, createInitialState). The third argument is an init function; React calls createInitialState(user) only on the first render. It's the same idea as useState(() => ...) (Topic 6.2), and the place to read things like localStorage once at start-up.

Explain it without notes

01

What is a reducer, and what does useReducer return?

02

Why should actions describe what happened rather than how state changes?

03

Why must reducers be pure, and what happens if one mutates state?

04

When and why would you pass a third argument to useReducer?

05

What does dispatch do, and is its identity stable?

Practice

01

Write a lightReducer for a traffic light with actions next (green → yellow → red → green) and broke (goes to "flashing"), plus repaired (back to "red"). Use it in a component with two buttons.

02

Convert this to useReducer: const [form, setForm] = useState({ name: "", email: "" }) with handlers setForm({ ...form, name: v }), setForm({ ...form, email: v }) and setForm({ name: "", email: "" }).

Trade-offs

  • ↔

    A reducer centralises update rules and makes them testable, at the cost of more code: an action shape, a switch and dispatch calls instead of direct setter calls.

  • ↔

    One reducer for a whole component keeps related changes together; one huge reducer for unrelated state becomes a dumping ground. Split reducers by concern, just like state variables.

Done when you can

  • I can write a reducer with a switch on action.type and a throwing default.

  • I can replace useState with useReducer and make handlers dispatch actions.

  • I name actions after what happened and put ids and timestamps into the action.

  • I can explain why reducers must be pure and what StrictMode's double call reveals.

  • I can use the init function argument for lazy initialisation.

  • I know dispatch is stable and doesn't change state in the running code.