cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

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.

By the end, you can
  • 01
    Convert 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.
  • 02
    Write 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.
  • 03
    Choose 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.

A cart with five handlers that each set statePop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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 with qty: 1.
  • handleIncrease and handleDecrease (lines 17 to 27) change one line's qty with 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, and handleEmpty (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.

Four cards in a row joined by arrows: click plus, dispatch an action with type increased, the function cartReducer, and the next items with Green tea times 2. A curved arrow goes from the next items back to the click: React renders the page with them.click +the page showsGreen tea × 1dispatch(action){ type: "increased" }cartReducergets the itemsand the actionnext itemsGreen tea × 2React renders the page with the next items
A click on + dispatches an action. React calls cartReducer with the current items and that action, and renders the page with the items it returns: Green tea × 1 becomes Green tea × 2.
Real-life analogyA bank's deposit slips

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
The main idea

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:

The five handlers, dispatching actionsJSX
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 product that was added, or the id of the line. You will often see the short form { type: "removed", id }, which means the same as id: 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.

Actions that say what happened
What the user didAn action that says what happenedAn 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.
Left, useState: five handler cards, each with its own arrow into items. Right, useReducer: five action cards, added, increased, decreased, removed, and emptied, all with arrows into one cartReducer function, which leads to items.useStatehandleAddhandleIncreasehandleDecreasehandleRemovehandleEmptyitemsuseReducer"added""increased""decreased""removed""emptied"cartReduceritems
With 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:

cartReducer, called by handPop out in the code editor (opens in a new tab)JavaScript
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 from handleAdd, reading the product from action.product.
  • "increased", "decreased", "removed", and "emptied" (lines 11 to 26) are the other four handlers' code, reading action.id.
  • default (lines 27 to 29) runs for any other type and throws an error. A typo like "increasd" then fails loudly with Unknown action: increasd instead 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:

A list of actions, reduced to one cartPop out in the code editor (opens in a new tab)JavaScript
// 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.

Five cart cards in a column, joined by four arrows. The first card is an empty cart; then Green tea times 1; Green tea times 1 and Mint tea times 1; Green tea times 2 and Mint tea times 1; and last Green tea times 2. Beside each arrow is the action that makes the next cart: added Green tea, added Mint tea, increased id 1, removed id 2.[ ] (empty)Green tea × 1Green tea × 1, Mint tea × 1Green tea × 2, Mint tea × 1Green tea × 2"added" Green tea"added" Mint tea"increased" id 1"removed" id 2the starting cartthe result
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.

The same cart with useReducerPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

Line 1 imports useReducer from React, and line 2 imports the reducer from its file. Line 6 is the only new idea:

The line that sets up the reducerJSX
const [items, dispatch] = useReducer(cartReducer, []);
  • useReducer takes two arguments here: the reducer, cartReducer, and the starting state, []. It can take a third, an init function that builds the starting state from the second argument on the first render; this lesson doesn't need it.
  • It returns two values, like useState does: items, the current state for this render, and dispatch, the function that sends actions to the reducer.
  • Like every hook, it goes at the top level of the component, not inside an if or 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:

Two actions from one clickPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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 quantityReducer once 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. qty belongs 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.

Playground: every action, with the cart before and after
The cartTotal: $0

The cart is empty.

Every action0 actions

No actions yet.

Try it yourself
Read the log

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.

This cart runs the lesson's cartReducer with useReducer. The log comes from a second reducer wrapped around cartReducer, which returns the cart and one log line per action, so it's pure too. The log shows the last six actions. Reset starts an empty cart and an empty log.

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:

A reducer that changes its state (a bug on purpose)Pop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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.

Two halves. Left, a pure reducer: the saved list, empty, goes into two calls, and both return a new list with Chai: the same result. Right, a reducer that uses push: the first call changes the saved list to Chai, the second call pushes again, so the list becomes Chai, Chai.return [...saved, name]saved: [ ]call 1call 2["Chai"]["Chai"]the same resultsaved is still [ ]saved.push(name)saved: [ ]call 1saved: ["Chai"]call 2saved: ["Chai", "Chai"]the page shows Chai, Chai
In development, StrictMode calls your reducer twice with the same state. The pure version gets the same untouched list both times, so both calls return a new list holding only Chai. The 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.

The fixed reducer: return a new arrayPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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:

A handler that does the outside work, then dispatchesJSX
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.

useState or useReducer
QuestionuseStateuseReducer
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:

A reducer for the cart, useState for a drawerJSX
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):

Checking cartReducer with plain function callsPop out in the code editor (opens in a new tab)JavaScript
// 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:

The same check with Vitest (in a project, not on this page)JavaScript
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.

A delivery form with a reducerPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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 shows Please enter a name.; otherwise it marks the form as sent.
  • "reset" (lines 16 to 18) returns emptyForm from 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.

Reducer or event handler?
  • 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");
Try it yourself
0 of 8 correct

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?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 type string 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.

Exercise 1 · Warm-upPredict the reduced quantity

What does this program print? Work through the actions one at a time, then press Edit & run to check.

Starter codePop out in the code editor (opens in a new tab)JavaScript
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));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upHandle the missing action

    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>    </>  );}
    ResultLoading…

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind why + adds two

      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>  );}
      ResultLoading…

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeMove the settings into a reducer

        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>    </>  );}
        ResultLoading…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeLet a check find the bug

          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?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          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"]);

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeAdd a stock limit in one place

            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>    </>  );}
            ResultLoading…

            Answer, then press Check. Spacing and letter case don’t matter.

              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.

              The useReducer quiz · 8 questionsScore: first tries count
              1. Question 1 of 8What does dispatch({ type: "increased", id: 1 }) do?

                Choose an answer to see the explanation.

              2. Question 2 of 8After one click on the button, what does it say?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                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, 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.

              3. Question 3 of 8This reducer pushes the name into saved and returns saved itself. After two clicks, what does the button say?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { 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.

              4. Question 4 of 8What does this replay of four actions print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // 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.

              5. Question 5 of 8Why does StrictMode call your reducer twice in development?

                Choose an answer to see the explanation.

              6. 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.

              7. Question 7 of 8A click should add a tea and send it to the server with fetch. Where does the fetch call go?

                Choose an answer to see the explanation.

              8. 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 dispatch actions 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 and dispatch. Like a set function, dispatch asks 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 useReducer when many handlers change one piece of state; it gathers the logic in one place you can log and test as a plain function. Otherwise useState is 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.

              CompleteFrontend Clear concepts. Working examples.