Extracting state logic into a reducer
Turn a cart's scattered setState calls into one reducer with useReducer: actions, dispatch, pure reducers, tests, and when useState is still the better fit.
- 01Convert state logic into a reducerTurn handlers that set state into handlers that dispatch actions, write the reducer with a switch, and connect it with useReducer.
- 02Write reducers that are pureReturn new objects and arrays instead of changing them, keep requests and clock reads in handlers, and explain StrictMode's double call.
- 03Choose and testDecide between useState and useReducer, and test a reducer by calling it as a plain function.
A cart with its updates in five places
A shop's cart changes in many ways. You add a tea, press + or - on a line, remove a line, or empty the whole cart. Here is a cart that does all five with useState. Click Add Green tea twice, Add Chai, then press - on Chai.
import { useState } from "react";import { products } from "./products.js"; export default function App() { const [items, setItems] = useState([]); function handleAdd(product) { if (items.some((item) => item.id === product.id)) { setItems(items.map((item) => item.id === product.id ? { ...item, qty: item.qty + 1 } : item )); } else { setItems([...items, { ...product, qty: 1 }]); } } function handleIncrease(id) { setItems(items.map((item) => item.id === id ? { ...item, qty: item.qty + 1 } : item )); } function handleDecrease(id) { setItems(items .map((item) => (item.id === id ? { ...item, qty: item.qty - 1 } : item)) .filter((item) => item.qty > 0)); } function handleRemove(id) { setItems(items.filter((item) => item.id !== id)); } function handleEmpty() { setItems([]); } const total = items.reduce((sum, item) => sum + item.price * item.qty, 0); return ( <> {products.map((product) => ( <button key={product.id} onClick={() => handleAdd(product)}> Add {product.name} </button> ))} <ul> {items.map((item) => ( <li key={item.id}> {item.name} × {item.qty}{" "} <button onClick={() => handleDecrease(item.id)}>-</button> <button onClick={() => handleIncrease(item.id)}>+</button> <button onClick={() => handleRemove(item.id)}>Remove</button> </li> ))} </ul> <p>Total: ${total}</p> <button onClick={handleEmpty}>Empty cart</button> </> );}Line 5 keeps the cart in state: an array of lines like { id: 1, name: "Green tea", price: 3, qty: 2 }, from the products in the products.js tab. Then come five event handlers, and each one works out the next array itself and passes it to setItems:
handleAdd(lines 7 to 15) adds 1 to the line if the tea is already in the cart, or adds a new line withqty: 1.handleIncreaseandhandleDecrease(lines 17 to 27) change one line'sqtywith map. Line 26 then drops any line that reached 0, so pressing - on Chai removed it.handleRemove(lines 29 to 31) filters one line out, andhandleEmpty(lines 33 to 35) sets an empty array.
Every handler follows the rules from Updating arrays in state: it makes a new array and never changes the old one. The cart works. But to answer "what can happen to this cart?", you have to read thirty lines of handlers, mixed in with the code that draws the page. Add a coupon, a stock limit, or an undo button, and the component gets longer still.
React gives you a second way to keep state, made for this: a reducer. You move every way the cart can change into one function outside the component. The handlers only say what happened, like "the user pressed + on Green tea", and that function works out the next cart. This lesson turns this cart into one with a reducer, in three steps.
cartReducer with the current items and that action, and renders the page with the items it returns: Green tea × 1 becomes Green tea × 2.At a bank, you don't walk into the safe and count out your new balance. You fill in a slip that says what you want, and a clerk applies the bank's rules to it. Every customer's slip goes through the same rules, and the bank keeps the slips, so it can always show how a balance came about.
- In real life: You fill in a slip: "deposit $20"
- In React: A handler dispatches an action:
{ type: "increased", id: 1 } - In real life: You hand the slip over; you don't open the safe
- In React: The handler calls
dispatch; it doesn't work out the next state - In real life: The clerk follows the bank's rules to get the new balance
- In React: The reducer works out the next state from the old one and the action
- In real life: The bank keeps every slip, so it can explain any balance
- In React: A list of actions explains how the state got where it is
A reducer is a function that takes the current state and an action, and returns the next state. With useReducer, your event handlers dispatch actions that describe what the user did, and React calls your reducer to work out the next state. Every way the state can change sits in one function you can read and test on its own.
Step 1: handlers say what happened
The first step changes only the handlers. Instead of working out the next array, each handler calls dispatch with an object that describes what the user just did. That object is called an action. Here are the five handlers after the change; step 3 shows where dispatch comes from, so this block is a piece of the component and doesn't run on its own:
function handleAdd(product) {
dispatch({ type: "added", product: product });
}
function handleIncrease(id) {
dispatch({ type: "increased", id: id });
}
function handleDecrease(id) {
dispatch({ type: "decreased", id: id });
}
function handleRemove(id) {
dispatch({ type: "removed", id: id });
}
function handleEmpty() {
dispatch({ type: "emptied" });
}Line 2 dispatches { type: "added", product: product }: "a product was added, and here it is". Line 6 dispatches { type: "increased", id: id }: "the + on this line was pressed". Lines 10 and 14 do the same for - and Remove, and line 18's action needs nothing but its type. The handlers are now one line each, and none of them mentions map, filter, or qty + 1.
An action is a plain JavaScript object. React doesn't look inside it, but the usual shape has two parts:
type: a string that names what happened, like"added"or"removed". The reducer reads it to choose what to do.- Any other fields the reducer needs to do it: the
productthat was added, or theidof the line. You will often see the short form{ type: "removed", id }, which means the same asid: id.
Name each action after what the user did, not after how the state should change. "Increased" is a click on +; "set the items to this array" is a decision the handler would have to make itself. How to read the table: each row is one click in the cart, an action that describes it, and a less useful one.
| What the user did | An action that says what happened | An action that says how to change state |
|---|---|---|
| Pressed + on Green tea | { type: "increased", id: 1 } | { type: "set_qty", id: 1, qty: 3 }: the handler had to work out 3 itself. |
| Pressed Add Chai | { type: "added", product: chai } | { type: "set_items", items: [...] }: the handler built the whole cart. |
| Pressed Remove on Mint tea | { type: "removed", id: 2 } | { type: "filter_items", id: 2 }: it names the array method, not the click. |
| Pressed Empty cart | { type: "emptied" } | Three "removed" actions, one per line: three actions for one click. |
useState, each of the five handlers works out the next items itself. With useReducer, the handlers only send actions, and one function, cartReducer, works out the next items for all five.Dispatching an action doesn't change anything by itself yet. Something has to read the action and work out the next cart. That's the reducer.
Step 2: one function works out the next state
A reducer is a plain function with two parameters, the current state and an action, that returns the next state. All the logic you took out of the handlers goes into it. Here it is, with three calls at the bottom so you can see what it returns:
function cartReducer(items, action) {
switch (action.type) {
case "added": {
if (items.some((item) => item.id === action.product.id)) {
return items.map((item) =>
item.id === action.product.id ? { ...item, qty: item.qty + 1 } : item
);
}
return [...items, { ...action.product, qty: 1 }];
}
case "increased": {
return items.map((item) =>
item.id === action.id ? { ...item, qty: item.qty + 1 } : item
);
}
case "decreased": {
return items
.map((item) => (item.id === action.id ? { ...item, qty: item.qty - 1 } : item))
.filter((item) => item.qty > 0);
}
case "removed": {
return items.filter((item) => item.id !== action.id);
}
case "emptied": {
return [];
}
default: {
throw Error("Unknown action: " + action.type);
}
}
}
const greenTea = { id: 1, name: "Green tea", price: 3 };
const before = [];
const after = cartReducer(before, { type: "added", product: greenTea });
const later = cartReducer(after, { type: "increased", id: 1 });
console.log(after.length, after[0].qty);
console.log(later[0].qty);
console.log(before.length, after[0].qty);Line 1 takes items, the cart as it is now, and action. Line 2 is a switch on action.type: one case per kind of action, and each case returns the next cart.
case "added"(lines 3 to 10) is the code fromhandleAdd, reading the product fromaction.product."increased","decreased","removed", and"emptied"(lines 11 to 26) are the other four handlers' code, readingaction.id.default(lines 27 to 29) runs for any other type and throws an error. A typo like"increasd"then fails loudly withUnknown action: increasdinstead of quietly doing nothing.
The braces after each case, like case "added": {, give the case its own block, so a const declared in one case can't clash with one in another. Each case returns, so no case runs into the next one.
Lines 35 and 36 call the reducer twice by hand, and lines 38 to 40 print what came back. Line 38 prints 1 1: after "added", the cart has one line, with qty 1. Line 39 prints 2: line 36 passed after and an "increased" action. Line 40 prints 0 1: before is still empty and after still says 1. Each call made a new array and left the old ones as they were. Press Edit & run and change line 36's type to "removed": line 39 now throws a TypeError, because the cart in later is empty and has no later[0].
Notice what's missing: React. The reducer doesn't import anything or call setItems. It's an ordinary pure function: the same cart and action always give the same next cart. That's why you can call it yourself, and test it, without a component.
Why it's called a reducer
The name comes from the array method reduce, which takes a list and "reduces" it to one value by calling your function once per item. A reducer has exactly the shape reduce wants: the result so far, and the next item. So a list of actions reduces to one cart:
// cartReducer is the function from the example above.
const actions = [
{ type: "added", product: { id: 1, name: "Green tea", price: 3 } },
{ type: "added", product: { id: 2, name: "Mint tea", price: 4 } },
{ type: "increased", id: 1 },
{ type: "removed", id: 2 },
];
const items = actions.reduce(cartReducer, []);
console.log(items.map((item) => item.name + " × " + item.qty).join(", "));Lines 2 to 7 list four actions, in the order a user might cause them. Line 9 starts from an empty cart, [], and calls cartReducer with each action in turn, passing each result to the next call. Line 10 prints Green tea × 2: two Green teas were added (one by "added", one by "increased"), and Mint tea was added and then removed.
actions.reduce(cartReducer, []) starts with an empty cart and calls cartReducer once per action. Each call takes the cart so far and one action, and returns the next cart. The last one, Green tea × 2, is the result.React does the same thing over time. Each click adds an action, and React reduces the cart one action at a time, keeping the result between renders.
Step 3: connect the reducer with useReducer
The last step swaps useState for useReducer, the hook that keeps state and runs your reducer on it. Here is the finished cart. cartReducer.js holds the reducer from Step 2, with export in front of it. The page works exactly as before: add teas, press + and -, and empty the cart.
import { useReducer } from "react";import { cartReducer } from "./cartReducer.js";import { products } from "./products.js"; export default function App() { const [items, dispatch] = useReducer(cartReducer, []); function handleAdd(product) { dispatch({ type: "added", product: product }); } function handleIncrease(id) { dispatch({ type: "increased", id: id }); } function handleDecrease(id) { dispatch({ type: "decreased", id: id }); } function handleRemove(id) { dispatch({ type: "removed", id: id }); } function handleEmpty() { dispatch({ type: "emptied" }); } const total = items.reduce((sum, item) => sum + item.price * item.qty, 0); return ( <> {products.map((product) => ( <button key={product.id} onClick={() => handleAdd(product)}> Add {product.name} </button> ))} <ul> {items.map((item) => ( <li key={item.id}> {item.name} × {item.qty}{" "} <button onClick={() => handleDecrease(item.id)}>-</button> <button onClick={() => handleIncrease(item.id)}>+</button> <button onClick={() => handleRemove(item.id)}>Remove</button> </li> ))} </ul> <p>Total: ${total}</p> <button onClick={handleEmpty}>Empty cart</button> </> );}Line 1 imports useReducer from React, and line 2 imports the reducer from its file. Line 6 is the only new idea:
const [items, dispatch] = useReducer(cartReducer, []);useReducertakes two arguments here: the reducer,cartReducer, and the starting state,[]. It can take a third, aninitfunction that builds the starting state from the second argument on the first render; this lesson doesn't need it.- It returns two values, like
useStatedoes:items, the current state for this render, anddispatch, the function that sends actions to the reducer. - Like every hook, it goes at the top level of the component, not inside an
ifor a loop.
Lines 8 to 26 are the handlers from Step 1, and lines 28 to 51 haven't changed at all. When you press + on Green tea, line 13 dispatches { type: "increased", id: 1 }. React puts the action in a queue and renders App again. During that render, useReducer calls cartReducer(items, action) and returns what it gave back as the new items.
dispatch asks for the next render
dispatch works like a set function from useState. It doesn't change items where you call it; it asks React for a render with the next state. This counter has an Add two button that dispatches twice and then logs. Click it, and read the console under the result:
import { useReducer } from "react"; function quantityReducer(qty, action) { switch (action.type) { case "increased": return qty + 1; case "reset": return 1; default: throw Error("Unknown action: " + action.type); }} export default function App() { const [qty, dispatch] = useReducer(quantityReducer, 1); function handleAddTwo() { dispatch({ type: "increased" }); dispatch({ type: "increased" }); console.log("qty during this click:", qty); } return ( <> <p>Green tea × {qty}</p> <button onClick={handleAddTwo}>Add two</button> <button onClick={() => dispatch({ type: "reset" })}>Reset</button> </> );}The state here is a number, not an array: a reducer works for state of any kind. One click shows Green tea × 3, and the console says qty during this click: 1. Two things happened:
- Both actions counted. React queued them and called
quantityReduceronce per action, each time with the result of the one before: 1, then 2, then 3. That's how the updater functions you passed to a set function work, too. - Line 20 still read 1.
qtybelongs to the render the click happened in. It's a snapshot, and the new value arrives with the next render.
You can watch the reducer do its work in the playground below. It runs the cart above with the lesson's cartReducer, and lists every action you dispatch, with the cart before it and after it.
Total: $0The cart is empty.
0 actionsNo actions yet.
Add two teas, press + and - on one of them, then Remove. Each click dispatches one action, and the log shows what cartReducer got and what it returned.
Press Add Green tea twice: the second action is "added" again, and the reducer turns it into Green tea × 2 rather than a second line. Press - twice on a tea with one: the reducer removes the line at 0. The log is the full story of the cart: if the cart is ever wrong, either an action in the log is wrong, or the reducer turned a right action into a wrong cart. That's two places to look, instead of every handler.
Reducers must be pure
React calls your reducer during rendering, when it works out the next state, not when you call dispatch. So a reducer follows the same rule as a component, from Keeping components pure: given the same state and action, it returns the same result, and it changes nothing outside itself. Two things break that rule.
Changing the state it was given
This saved-teas list has a reducer that adds a name with push. Click Save Chai once:
import { useReducer } from "react"; function savedReducer(saved, action) { switch (action.type) { case "saved": { saved.push(action.name); return [...saved]; } case "cleared": { return []; } default: { throw Error("Unknown action: " + action.type); } }} export default function App() { const [saved, dispatch] = useReducer(savedReducer, []); return ( <> <button onClick={() => dispatch({ type: "saved", name: "Chai" })}>Save Chai</button> <button onClick={() => dispatch({ type: "cleared" })}>Clear</button> <p>Saved: {saved.join(", ") || "nothing yet"}</p> </> );}One click shows Saved: Chai, Chai. Line 6 pushes the name into saved, the array React is keeping as the current state, and line 7 returns a copy. In development, every example on this page runs inside <StrictMode>, and StrictMode calls your reducer twice with the same state, to catch exactly this. The first call changed saved, so the second call found Chai already in it and pushed again.
push version changes the list it was given, so the second call sees the first call's change and adds Chai again.A production build calls the reducer once, so users would see one Chai. The bug is still there: the reducer changed the array React keeps as the current state, without an action saying so, and the next render reads a list no action produced. The fix is the rule from Updating arrays in state: make a new array. Press Edit & run and replace lines 6 and 7 with return [...saved, action.name];. Now one click saves Chai once: StrictMode's second call gets the same untouched state, so it returns a new array with the same contents as the first.
import { useReducer } from "react"; function savedReducer(saved, action) { switch (action.type) { case "saved": { return [...saved, action.name]; } case "cleared": { return []; } default: { throw Error("Unknown action: " + action.type); } }} export default function App() { const [saved, dispatch] = useReducer(savedReducer, []); return ( <> <button onClick={() => dispatch({ type: "saved", name: "Chai" })}>Save Chai</button> <button onClick={() => dispatch({ type: "cleared" })}>Clear</button> <p>Saved: {saved.join(", ") || "nothing yet"}</p> </> );}A reducer that changes the array and then returns that same array has a different symptom: nothing happens on the page. React compares the old and the new state with Object.is, sees the same array, and skips the render. Either way, the cure is the same: copy, never change.
Doing work outside the reducer's job
A reducer only works out the next state. Sending a request, saving to localStorage, showing an alert, starting a timer, or reading the clock with Date.now() or Math.random() all reach outside, or give a different answer each call. Those belong in the event handler, which runs once per click. If the reducer needs the result, put it in the action:
function handleAdd(product) {
const addedAt = Date.now();
dispatch({ type: "added", product: product, addedAt: addedAt });
fetch("/api/cart", { method: "POST", body: JSON.stringify(product) });
}Line 2 reads the clock once, in the handler. Line 3 puts the time in the action as addedAt, so the reducer can store it without reading the clock itself. Line 4 tells the server, also from the handler. (Talking to a server while a component is on the screen, without a click, is the job of Effects.)
useState or useReducer?
Both hooks keep state, and anything you can write with one, you can write with the other. Choose by how the state changes. How to read the table: each row is one question to ask, with an answer for each hook.
| Question | useState | useReducer |
|---|---|---|
| How much code do you write? | Less: one set call in each handler. | More at first: a reducer and its actions. Less when many handlers change state in similar ways. |
| Where is the update logic? | Spread across the event handlers. | In one function, next to every other way the state can change. |
| How do you find a wrong update? | Look through every handler that calls the set function. | Log the actions. If they're right, the bug is in the reducer. |
| How do you test it? | Through the component, by clicking. | Call the reducer as a plain function with a state and an action. |
| When does it fit best? | A few simple values, like a switch or a search box. | One piece of state with many kinds of change, like a cart or a form. |
A good rule: start with useState. Switch to a reducer when one piece of state is changed by many handlers, when the changes follow rules (a stock limit, a line removed at 0), or when you keep finding bugs in how the state gets updated. You can also use both in one component:
const [items, dispatch] = useReducer(cartReducer, []);
const [isOpen, setIsOpen] = useState(false);Line 1 keeps the cart in a reducer, because many clicks change it in many ways. Line 2 keeps whether the cart drawer is open in useState, because it's one value with one simple change.
Testing a reducer as a plain function
Because a reducer is a pure function that doesn't need React, you can test it by calling it and checking what it returns. These checks use only JavaScript (the function is the one from Step 2):
// cartReducer is the function from Step 2.
function check(name, actual, expected) {
const passed = JSON.stringify(actual) === JSON.stringify(expected);
console.log((passed ? "pass: " : "FAIL: ") + name);
}
const tea = { id: 1, name: "Green tea", price: 3 };
const oneTea = [{ id: 1, name: "Green tea", price: 3, qty: 1 }];
check("added puts a new line in", cartReducer([], { type: "added", product: tea }), oneTea);
check("decreased at 1 removes the line", cartReducer(oneTea, { type: "decreased", id: 1 }), []);
check("the old state is not changed", oneTea[0].qty, 1);check on lines 2 to 5 compares what the reducer returned with what you expected, by turning both into JSON text, and prints pass or FAIL. Line 10 checks that "added" puts in a line with qty: 1, line 11 that "decreased" at 1 removes the line, and line 12 that the old state wasn't changed by line 11. All three print pass. Press Edit & run and change line 11's expected [] to oneTea: that check now prints FAIL.
In a real project you would write the same checks with a test runner such as Vitest, which the Unit testing lesson introduces. The test imports the reducer from its file and never renders a component:
import { expect, test } from "vitest";
import { cartReducer } from "./cartReducer.js";
test("decreased at 1 removes the line", () => {
const items = [{ id: 1, name: "Green tea", price: 3, qty: 1 }];
const next = cartReducer(items, { type: "decreased", id: 1 });
expect(next).toEqual([]);
});Line 2 imports cartReducer from cartReducer.js. Line 6 calls it, and line 7 says the result must equal an empty array. If someone later breaks the "decreased" case, this test fails before any user sees the bug.
A delivery form with one action per click
Reducers aren't only for lists. A form with several fields, a check before sending, and a way to start over is a good fit too. Type a name, tick Express delivery, press Send, then Start over. Also try Send with an empty name.
import { useReducer } from "react"; const emptyForm = { name: "", express: false, giftWrap: false, sent: false, error: "" }; function formReducer(form, action) { switch (action.type) { case "edited": { return { ...form, [action.field]: action.value, error: "" }; } case "submitted": { if (form.name.trim() === "") { return { ...form, error: "Please enter a name." }; } return { ...form, sent: true }; } case "reset": { return emptyForm; } default: { throw Error("Unknown action: " + action.type); } }} export default function App() { const [form, dispatch] = useReducer(formReducer, emptyForm); function edit(field, value) { dispatch({ type: "edited", field: field, value: value }); } if (form.sent) { return ( <> <p> Thanks, {form.name}. {form.express ? "Express" : "Standard"} delivery {form.giftWrap ? ", gift wrapped." : "."} </p> <button onClick={() => dispatch({ type: "reset" })}>Start over</button> </> ); } return ( <> <label> Name <input value={form.name} onChange={(e) => edit("name", e.target.value)} /> </label> <label> <input type="checkbox" checked={form.express} onChange={(e) => edit("express", e.target.checked)} /> Express delivery </label> <label> <input type="checkbox" checked={form.giftWrap} onChange={(e) => edit("giftWrap", e.target.checked)} /> Gift wrap </label> <p>{form.error}</p> <button onClick={() => dispatch({ type: "submitted" })}>Send</button> <button onClick={() => dispatch({ type: "reset" })}>Start over</button> </> );}Line 3 is the whole form as one object: three fields, whether it was sent, and an error message. formReducer handles three actions:
"edited"(lines 7 to 9) copies the form with one field changed.[action.field]is a computed key: the action says which field, so the text box and both checkboxes share one case. It also clears the error."submitted"(lines 10 to 15) checks the name. With no name, it returns the form with an error, and the page showsPlease enter a name.; otherwise it marks the form as sent."reset"(lines 16 to 18) returnsemptyFormfrom line 3. Returning that same object every time is fine: the reducer never changes it, it only copies it.
Lines 28 to 30 wrap the "edited" action in a small edit function the inputs share. When form.sent is true, lines 32 to 42 show the thank-you message instead of the form.
Look at Start over. One click puts back five fields, and it's one action, { type: "reset" }, not five "edited" actions. Give each action the meaning of one thing the user did, even when it changes many fields. Then a log of the actions reads like a story of the user's visit: edited, edited, submitted, reset.
return [...items, { ...action.product, qty: 1 }];fetch("/api/cart", { method: "POST" });return items.filter((item) => item.id !== action.id);localStorage.setItem("cart", json);throw Error("Unknown action: " + action.type);const addedAt = Date.now();return { ...form, sent: true };alert("Added to your cart");
Sort each line: does it belong in the reducer, which only works out the next state, or in the event handler, which can reach outside?
Common mix-ups
- "dispatch changes the state right away." No. It queues the action and asks for a render. The variable you read in the same handler keeps its old value; Add two logged 1 and then showed 3.
- "The reducer runs when I call dispatch." No. React calls it while rendering the next state, and StrictMode calls it twice in development. That's why it has to be pure.
- "A reducer changes the state it gets." No. It returns a new state and leaves the old one as it was. Changing it saved Chai twice, or left the page as it was.
- "useReducer is better than useState." Neither is better. They can do the same things; a reducer is more code that pays off when many handlers change one piece of state.
- "Actions must have a type field." No. An action can be any value. A
typestring is a convention that makes reducers easy to read, and almost everyone follows it. - "useReducer is for big apps, like Redux." No. It's a built-in hook for one component's state, useful in a small form. Libraries like Redux use the same reducer idea for a whole app's state.
Practice exercises
The JavaScript exercises print their answer when you press Edit & run. The React ones show their result under the code, and update as you type. Guess first, then use the hints.
What does this program print? Work through the actions one at a time, then press Edit & run to check.
function quantityReducer(qty, action) {
switch (action.type) {
case "increased":
return qty + 1;
case "decreased":
return Math.max(qty - 1, 0);
case "reset":
return 1;
default:
throw Error("Unknown action: " + action.type);
}
}
const actions = [
{ type: "increased" },
{ type: "reset" },
{ type: "decreased" },
{ type: "decreased" },
{ type: "increased" },
];
console.log(actions.reduce(quantityReducer, 3));function quantityReducer(qty, action) {
switch (action.type) {
case "increased":
return qty + 1;
case "decreased":
return Math.max(qty - 1, 0);
case "reset":
return 1;
default:
throw Error("Unknown action: " + action.type);
}
}
const actions = [
{ type: "increased" },
{ type: "reset" },
{ type: "decreased" },
{ type: "decreased" },
{ type: "increased" },
];
console.log(actions.reduce(quantityReducer, 3));reduce calls quantityReducer once per action, passing each result on: 3 + 1 = 4, "reset" gives 1, "decreased" gives 0, the next "decreased" stays at 0 because of Math.max, and "increased" gives 1. It prints 1.
Press - in the result, and read the error. Then add the missing case, so - takes one away but never goes below 1. After your fix, what does the page show after -, -, +?
import { useReducer } from "react"; function quantityReducer(qty, action) { switch (action.type) { case "increased": return qty + 1; default: throw Error("Unknown action: " + action.type); }} export default function App() { const [qty, dispatch] = useReducer(quantityReducer, 2); return ( <> <p>Mint tea × {qty}</p> <button onClick={() => dispatch({ type: "decreased" })}>-</button> <button onClick={() => dispatch({ type: "increased" })}>+</button> </> );}import { useReducer } from "react"; function quantityReducer(qty, action) { switch (action.type) { case "increased": return qty + 1; case "decreased": return Math.max(qty - 1, 1); default: throw Error("Unknown action: " + action.type); }} export default function App() { const [qty, dispatch] = useReducer(quantityReducer, 2); return ( <> <p>Mint tea × {qty}</p> <button onClick={() => dispatch({ type: "decreased" })}>-</button> <button onClick={() => dispatch({ type: "increased" })}>+</button> </> );}The - button dispatches "decreased", which had no case, so default threw Unknown action: decreased. The new case returns one less, but never less than 1. From 2: - gives 1, - stays at 1, + gives 2.
Click + once. What does the line show? Then fix the reducer so one click adds exactly one.
import { useReducer } from "react"; function cartReducer(items, action) { switch (action.type) { case "increased": { return items.map((item) => { if (item.id === action.id) { item.qty = item.qty + 1; } return item; }); } default: { throw Error("Unknown action: " + action.type); } }} const startItems = [{ id: 1, name: "Green tea", price: 3, qty: 1 }]; export default function App() { const [items, dispatch] = useReducer(cartReducer, startItems); return ( <ul> {items.map((item) => ( <li key={item.id}> {item.name} × {item.qty}{" "} <button onClick={() => dispatch({ type: "increased", id: item.id })}>+</button> </li> ))} </ul> );}import { useReducer } from "react"; function cartReducer(items, action) { switch (action.type) { case "increased": { return items.map((item) => item.id === action.id ? { ...item, qty: item.qty + 1 } : item ); } default: { throw Error("Unknown action: " + action.type); } }} const startItems = [{ id: 1, name: "Green tea", price: 3, qty: 1 }]; export default function App() { const [items, dispatch] = useReducer(cartReducer, startItems); return ( <ul> {items.map((item) => ( <li key={item.id}> {item.name} × {item.qty}{" "} <button onClick={() => dispatch({ type: "increased", id: item.id })}>+</button> </li> ))} </ul> );}map makes a new array, but line 8 changed the item object inside the current state. StrictMode calls the reducer twice with that state, and the second call found qty already at 2 and made it 3. The fix copies the item with spread, so both calls return a line with qty 2.
These notification settings use useState. Rewrite them with useReducer and two actions. The page should work the same: press SMS, Turn all off, then Push. What do the three settings say?
import { useState } from "react"; export default function App() { const [settings, setSettings] = useState({ email: true, sms: false, push: true }); function handleToggle(name) { setSettings({ ...settings, [name]: !settings[name] }); } function handleAllOff() { setSettings({ email: false, sms: false, push: false }); } return ( <> <button onClick={() => handleToggle("email")}>Email: {settings.email ? "on" : "off"}</button> <button onClick={() => handleToggle("sms")}>SMS: {settings.sms ? "on" : "off"}</button> <button onClick={() => handleToggle("push")}>Push: {settings.push ? "on" : "off"}</button> <button onClick={handleAllOff}>Turn all off</button> </> );}import { useReducer } from "react"; const startSettings = { email: true, sms: false, push: true }; function settingsReducer(settings, action) { switch (action.type) { case "toggled": { return { ...settings, [action.name]: !settings[action.name] }; } case "all_off": { return { email: false, sms: false, push: false }; } default: { throw Error("Unknown action: " + action.type); } }} export default function App() { const [settings, dispatch] = useReducer(settingsReducer, startSettings); function handleToggle(name) { dispatch({ type: "toggled", name: name }); } function handleAllOff() { dispatch({ type: "all_off" }); } return ( <> <button onClick={() => handleToggle("email")}>Email: {settings.email ? "on" : "off"}</button> <button onClick={() => handleToggle("sms")}>SMS: {settings.sms ? "on" : "off"}</button> <button onClick={() => handleToggle("push")}>Push: {settings.push ? "on" : "off"}</button> <button onClick={handleAllOff}>Turn all off</button> </> );}The two handlers now dispatch { type: "toggled", name } and { type: "all_off" }, and settingsReducer holds the update logic. The page behaves exactly as before: SMS turns on, Turn all off sets all three to off, and Push turns back on.
Three checks test a reducer for a saved-teas list. Run them, find the check that fails, and fix the reducer, not the check. After your fix, what does the third line print?
function savedReducer(saved, action) {
switch (action.type) {
case "saved":
return saved.includes(action.name) ? saved : [...saved, action.name];
case "removed":
return saved.filter((name) => name === action.name);
case "cleared":
return [];
default:
throw Error("Unknown action: " + action.type);
}
}
function check(name, actual, expected) {
const passed = JSON.stringify(actual) === JSON.stringify(expected);
console.log((passed ? "pass: " : "FAIL: ") + name);
}
const one = ["Chai"];
const two = ["Chai", "Mint tea"];
check("saved adds a name", savedReducer([], { type: "saved", name: "Chai" }), ["Chai"]);
check("saved skips a name it has", savedReducer(one, { type: "saved", name: "Chai" }), ["Chai"]);
check("removed takes the name out", savedReducer(two, { type: "removed", name: "Chai" }), ["Mint tea"]);function savedReducer(saved, action) {
switch (action.type) {
case "saved":
return saved.includes(action.name) ? saved : [...saved, action.name];
case "removed":
return saved.filter((name) => name !== action.name);
case "cleared":
return [];
default:
throw Error("Unknown action: " + action.type);
}
}
function check(name, actual, expected) {
const passed = JSON.stringify(actual) === JSON.stringify(expected);
console.log((passed ? "pass: " : "FAIL: ") + name);
}
const one = ["Chai"];
const two = ["Chai", "Mint tea"];
check("saved adds a name", savedReducer([], { type: "saved", name: "Chai" }), ["Chai"]);
check("saved skips a name it has", savedReducer(one, { type: "saved", name: "Chai" }), ["Chai"]);
check("removed takes the name out", savedReducer(two, { type: "removed", name: "Chai" }), ["Mint tea"]);The "removed" case kept only the name it should remove, so the third check got ["Chai"] instead of ["Mint tea"] and printed FAIL. With !==, filter keeps every other name, and the third line prints pass: removed takes the name out.
The shop has 5 of each tea. Change the reducer in the cartReducer.js tab so no line goes above 5, whichever button adds. Then click Add Green tea seven times. What does the line say?
import { useReducer } from "react";import { cartReducer } from "./cartReducer.js";import { products } from "./products.js"; export default function App() { const [items, dispatch] = useReducer(cartReducer, []); function handleAdd(product) { dispatch({ type: "added", product: product }); } function handleIncrease(id) { dispatch({ type: "increased", id: id }); } function handleDecrease(id) { dispatch({ type: "decreased", id: id }); } function handleRemove(id) { dispatch({ type: "removed", id: id }); } function handleEmpty() { dispatch({ type: "emptied" }); } const total = items.reduce((sum, item) => sum + item.price * item.qty, 0); return ( <> {products.map((product) => ( <button key={product.id} onClick={() => handleAdd(product)}> Add {product.name} </button> ))} <ul> {items.map((item) => ( <li key={item.id}> {item.name} × {item.qty}{" "} <button onClick={() => handleDecrease(item.id)}>-</button> <button onClick={() => handleIncrease(item.id)}>+</button> <button onClick={() => handleRemove(item.id)}>Remove</button> </li> ))} </ul> <p>Total: ${total}</p> <button onClick={handleEmpty}>Empty cart</button> </> );}import { useReducer } from "react";import { cartReducer } from "./cartReducer.js";import { products } from "./products.js"; export default function App() { const [items, dispatch] = useReducer(cartReducer, []); function handleAdd(product) { dispatch({ type: "added", product: product }); } function handleIncrease(id) { dispatch({ type: "increased", id: id }); } function handleDecrease(id) { dispatch({ type: "decreased", id: id }); } function handleRemove(id) { dispatch({ type: "removed", id: id }); } function handleEmpty() { dispatch({ type: "emptied" }); } const total = items.reduce((sum, item) => sum + item.price * item.qty, 0); return ( <> {products.map((product) => ( <button key={product.id} onClick={() => handleAdd(product)}> Add {product.name} </button> ))} <ul> {items.map((item) => ( <li key={item.id}> {item.name} × {item.qty}{" "} <button onClick={() => handleDecrease(item.id)}>-</button> <button onClick={() => handleIncrease(item.id)}>+</button> <button onClick={() => handleRemove(item.id)}>Remove</button> </li> ))} </ul> <p>Total: ${total}</p> <button onClick={handleEmpty}>Empty cart</button> </> );}Both cases that add now stop at 5, so the Add button and the + button follow the same rule. The rule lives in the reducer, so no handler needs to know about it, and a new button that dispatches "increased" follows it too.
Check your understanding
For each code question, follow the actions one at a time, and remember when React calls the reducer. Press Edit & run on a question's code to check your answer.
Question 1 of 8What does
dispatch({ type: "increased", id: 1 })do?Choose an answer to see the explanation.
Question 2 of 8After one click on the button, what does it say?
Read the code, then predictimport { useReducer } from "react"; function quantityReducer(qty, action) { switch (action.type) { case "increased": return qty + 1; default: throw Error("Unknown action: " + action.type); } } export default function App() { const [qty, dispatch] = useReducer(quantityReducer, 1); function handleClick() { dispatch({ type: "increased" }); dispatch({ type: "increased" }); dispatch({ type: "increased" }); } return <button onClick={handleClick}>Chai × {qty}</button>; }Choose an answer to see the explanation.
Question 3 of 8This reducer pushes the name into
savedand returnssaveditself. After two clicks, what does the button say?Read the code, then predictimport { useReducer } from "react"; function savedReducer(saved, action) { switch (action.type) { case "saved": { saved.push(action.name); return saved; } default: { throw Error("Unknown action: " + action.type); } } } export default function App() { const [saved, dispatch] = useReducer(savedReducer, []); return ( <button onClick={() => dispatch({ type: "saved", name: "Chai" })}> Saved: {saved.length} </button> ); }Choose an answer to see the explanation.
Question 4 of 8What does this replay of four actions print?
Read the code, then predict// cartReducer is the function from Step 2. const actions = [ { type: "added", product: { id: 3, name: "Chai", price: 5 } }, { type: "increased", id: 3 }, { type: "decreased", id: 3 }, { type: "decreased", id: 3 }, ]; const items = actions.reduce(cartReducer, []); console.log(items.length);Choose an answer to see the explanation.
Question 5 of 8Why does StrictMode call your reducer twice in development?
Choose an answer to see the explanation.
Question 6 of 8A form with five fields has a Start over button. Which dispatch fits a click on it best?
Choose an answer to see the explanation.
Question 7 of 8A click should add a tea and send it to the server with
fetch. Where does thefetchcall go?Choose an answer to see the explanation.
Question 8 of 8Which state is the best fit for plain
useState?Choose an answer to see the explanation.
Key takeaways
- A reducer is a function that takes the current state and an action, and returns the next state.
- Event handlers
dispatchactions that say what the user did, like{ type: "increased", id: 1 }, and the reducer decides how the state changes. useReducer(reducer, initialState)returns the current state anddispatch. Like a set function,dispatchasks for the next render; the reducer gets each queued action in turn.- Reducers must be pure: copy instead of changing, and do requests, storage, and clock reads in the handler. StrictMode calls them twice in development to catch mistakes.
- Choose
useReducerwhen many handlers change one piece of state; it gathers the logic in one place you can log and test as a plain function. OtherwiseuseStateis simpler.
Remember the one-liner.
Handlers say what happened; the reducer says what changes.
Coming next: Passing data deeply with context. A reducer keeps the logic of one component's state in one place; context lets that component hand data to components far below it, without passing props through every level.