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:
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.
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:
| Action | Data it carries | What it does |
|---|---|---|
added | book (a complete book object) | Adds the book to the end of the list |
statusChanged | id, status | Sets one book's status |
deleted | id | Removes one book |
rated | id, rating | Sets one book's rating from 1 to 5 |
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.
Implement the rated action
- In
booksReducer.js, make theratedcase setratingon the matching book. - In
App.js, show five small buttons (1 to 5) next to books with status'done'. Clicking one dispatchesrated.
Show solution
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.
When is useReducer a better choice than useState?
Day 4 progress