Skip to Content
React in a Week
Day 4 · State & escape hatches

Extracting logic into a reducer

Move state update logic out of your event handlers and into one pure function with useReducer.

By the end of Day 3 your App had a handful of event handlers: one to add a book, one to cycle its status, one to delete it. Each one calls setBooks with its own little piece of logic. As an app grows, this logic gets spread across many handlers and becomes hard to see at a glance.

A reducer gathers all of those updates into one function, outside your component. Your handlers shrink to one line each that says what happened, and the reducer decides how the state changes.

From setState to dispatch

Here is a small to-read list written with useState. Look at how each handler contains its own update logic.

function handleAdd(title) {
  setBooks([...books, { id: crypto.randomUUID(), title, done: false }]);
}

function handleToggle(id) {
  setBooks(books.map((b) => (b.id === id ? { ...b, done: !b.done } : b)));
}

function handleDelete(id) {
  setBooks(books.filter((b) => b.id !== id));
}

With a reducer, you rewrite it in three steps.

Step 1. Dispatch actions instead of setting state. An action is a plain object describing what the user did. By convention it has a type string, plus whatever data the update needs.

function handleToggle(id) {
  dispatch({ type: 'toggled', id });
}

Step 2. Write a reducer function. A reducer takes the current state and an action, and returns the next state. Most people use a switch on action.type.

function booksReducer(books, action) {
  switch (action.type) {
    case 'toggled':
      return books.map((b) => (b.id === action.id ? { ...b, done: !b.done } : b));
    // ...one case per action
    default:
      throw new Error('Unknown action: ' + action.type);
  }
}

Step 3. Use the reducer in your component. Swap useState for useReducer. It takes the reducer and the initial state, and returns the current state plus a dispatch function.

import { useReducer } from 'react';

const [books, dispatch] = useReducer(booksReducer, initialBooks);

Here is the whole thing working:

Live code· A to-read list with useReducer

Notice that booksReducer sits outside the component. It doesn't use any hooks or any variables from App. That makes it easy to read, move to its own file, and test (you will write tests for it on Day 6).

Deep diveWhy is it called a reducer?

The name comes from the JavaScript array method reduce(), which you saw on Day 1. reduce takes a running total and the next item and returns the new total. A React reducer takes the current state and the next action and returns the new state. In fact, if you have an array of actions, you can replay all of them with actions.reduce(booksReducer, initialBooks).

Reducers must be pure

React may call your reducer more than once. In development, Strict Mode deliberately calls it twice to help you spot mistakes. So a reducer must be pure, like a component:

  • Same inputs, same output. No random numbers, no dates, no IDs created inside.
  • No changes to the existing state. Return a new array or object instead.
  • No side effects. No fetching, no timers, no localStorage.
Quick check

What should a reducer return when it handles an action?

Project: the Reading List booksReducer

Now apply this to your Reading List. The reducer handles four actions, named in the past tense because each describes something that happened:

ActionData it carriesWhat it does
addedbook (a complete book object)Adds the book to the end of the list
statusChangedid, statusSets one book's status
deletedidRemoves one book
ratedid, ratingSets one book's rating from 1 to 5
Live code· Reading List: booksReducer

The curly braces around each case body give every case its own scope, so you can declare variables with the same name in different cases without errors.

Try it

Implement the rated action

  1. In booksReducer.js, make the rated case set rating on the matching book.
  2. In App.js, show five small buttons (1 to 5) next to books with status 'done'. Clicking one dispatches rated.
Show solution
booksReducer.js
case 'rated': {
  return books.map((b) => (b.id === action.id ? { ...b, rating: action.rating } : b));
}

In App.js, inside the <li>:

{book.status === 'done' && (
  <span aria-label="Rate this book">
    {[1, 2, 3, 4, 5].map((n) => (
      <button
        key={n}
        aria-pressed={book.rating === n}
        onClick={() => dispatch({ type: 'rated', id: book.id, rating: n })}
      >
        {n <= (book.rating ?? 0) ? '\u2605' : '\u2606'}
      </button>
    ))}
  </span>
)}

The \u2605 and \u2606 escapes are a filled star and an empty star. The handler only describes what happened; the reducer decides how books changes.

Quick check

When is useReducer a better choice than useState?

Day 4 progress

0/10

On this page