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) => nextStatethat 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.typethat tells the reducer which case to run. - dispatch
- The function from
useReducerthat 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.
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.
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.
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.
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.
06Initialise lazily when it's expensive
Pass the argument and an init function separately, and React runs the init function only once.
// ❌ 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
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.stateis this render's snapshot; the new value arrives in the next render, exactly likeuseState.) - 2
Add a new action
Give the counter a
doubledaction and a ×2 button. Which file or function do you change for the rule itself? (OnlycounterReducer: the button just dispatches{ type: "doubled" }.) - 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
The script clicks +, +, Step 5, +, Reset. Every button only dispatches an action; how the numbers change is decided in one place, `counterReducer`.
Preview
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 5Add, 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
Console
Nothing logged yet.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
Console
Nothing logged yet.Expected console output
Milk, Milk
MilkBoth 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
Console
Nothing logged yet.Expected console output
createInitialTodos ran for eager
createInitialTodos ran for lazy
--- click both buttons
createInitialTodos ran for eagerBreak 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.
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.
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
useStateis implemented asuseReducerwith a built-in reducer,(state, action) => typeof action === "function" ? action(state) : action. That's whysetX(prev => ...)updater functions anddispatchqueue up and process in the same way, in order, during the next render. - ▸
Older versions could run the reducer eagerly inside
dispatchto 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
submittedwhile alreadysubmitting). Libraries like XState formalise that (Topic 15.5).
Remember this
- 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'sarray.reduce((total, item) => ..., start), which also turns "the result so far plus one more thing" into a new result. - 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 atypestring (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
Inside the component you write
const [state, dispatch] = useReducer(tasksReducer, initialTasks).stateis the current value, just like withuseState. **dispatch** is a function that sends an action:dispatch({ type: "deleted", id: 2 }). React queues the action, and during the next render callstasksReducer(currentState, action)and uses the returned value as the new state. Like a state setter,dispatchdoesn't changestatein the code that's already running: afterdispatch(...), thestatevariable still holds this render's snapshot (Topic 4.4). Anddispatchis stable: it's the same function on every render, so it's safe to pass down and to leave out of effect dependencies. - 4
Inside the reducer, the usual style is a **
switchonaction.type**, with onecaseper action and a block{ }around each case so itsconstnames don't clash. Each case returns a new state built with the immutable patterns from Topics 4.6 and 4.7 (spread,map,filter). Thedefaultcase 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 withifstatements; theswitchis just the common habit. - 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
stateoraction, 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
Lazy initialisation.
useReducer(reducer, createInitialState(user))callscreateInitialStateon 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 callscreateInitialState(user)only on the first render. It's the same idea asuseState(() => ...)(Topic 6.2), and the place to read things likelocalStorageonce at start-up.
Explain it without notes
What is a reducer, and what does useReducer return?
Why should actions describe what happened rather than how state changes?
Why must reducers be pure, and what happens if one mutates state?
When and why would you pass a third argument to useReducer?
What does dispatch do, and is its identity stable?
Practice
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.
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
switchonaction.typeand a throwingdefault.I can replace
useStatewithuseReducerand 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
dispatchis stable and doesn't changestatein the running code.