Putting it together: a todo app
A capstone that uses the whole course at once — components, props, a list with keys, controlled input, a reducer for the state, and an effect that keeps the tab title honest.
Where the pieces land
One file, six ideas from the course. The todos live in a useReducer — the state is a list, and every change is an action with a name: add, toggle, remove. Components take props and report upward. The list renders with keys from the data. The input is controlled. An effect syncs the page title with how much is left.
A reducer is a function — (state, action) => nextState — and it is the pattern to reach for when state has several shapes to it. Adding a feature means adding a case, not another setter threaded through the components.
Why the updates make new values
The reducer never changes the array it was given. Each case builds a new one: [...todos, newTodo] for an add, a map for a toggle, a filter for a remove. React compares the value it has with the value the reducer returned, and a mutated array is the same object — invisible, and the page does not change.
That is the whole reason for the spread in a reducer: not style, but the only way React can see that something happened.
What a real app adds next
The interesting additions from here are all outside this preview: saving the list to a server or to storage, a router so each view has a URL, and fetching — each of which arrives as a new effect or as a library. The shape of the app does not change when they do.
One habit worth keeping from this file: the filter is derived from the todos at render, the count is derived too, and only three things are stored — the todos, the input, and the filter. State holds the smallest truth, and everything the page shows is worked out from it.
One action, one transition
With several useState calls, a single user action often means two setters that must agree. A reducer moves that agreement into one function: dispatch({ type: "add", text }) and every field the action touches changes together, in one transition the page can never see half of. A render sees the state before the action or the state after it, never a mixture of the two.
The reducer is where the rules live
Rules about the list belong in the reducer, not in the components: what an add does to the list, what a toggle flips, what a remove drops. The components then only describe what the reader did, and the rules can be read in one place and tested by calling the function with a state and an action.
A reducer, and the actions it answers
function reducer(todos, action) {
switch (action.type) {
case 'add':
// A new array: the old one is never touched.
return [...todos, { id: action.id, text: action.text, done: false }];
case 'toggle':
return todos.map((todo) =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
);
case 'remove':
return todos.filter((todo) => todo.id !== action.id);
default:
// An unknown action changes nothing.
return todos;
}
}
// The component sends actions, never the next state directly:
// dispatch({ type: 'add', id: 7, text: 'Learn useReducer' })
// dispatch({ type: 'toggle', id: 7 })Actions describe what happened
A reducer is a function of two values: the state it has and the action it was sent. It returns the next state and does nothing else — no requests, no storage, no console. That purity is what makes the transitions easy to test: call the function with a state and an action, compare the result.
dispatch is stable across renders and does not read the state, so a handler can send an action without closing over the current list. The action carries what the reducer cannot know — the new text, the id to remove — and the reducer decides what that means.
Derive the view from the state
The filtered list, the open count and the empty message are all functions of the todos. Computing them during render means they cannot drift; storing them would mean a second update on every change, and a day when one of the two is forgotten.
The same rule covers the title effect: it reads open, which is derived, and writes to something outside React. The effect has one dependency and one job. An effect that also filtered the list would be doing render's work after the paint, one step behind the reader.
Three reducer mistakes: changing the array in place, so React sees the same reference and skips the render; storing a filtered copy in state alongside the source list; and dispatching during render instead of in a handler or an effect. A dispatch during render is a state update loop waiting to happen.
Try these next
- Add a
clear doneaction and a button that sends it. - Add an
editaction that changes one todo text, and keep the array immutable. - Show a count per filter next to each filter button, derived from the same list.
Today, in one file
Run it: add a todo, tick one off, remove one, filter the list. Then add a "clear done" button that dispatches a new action, with its own case in the reducer.
A quiz driven by a reducer
Run it and answer the three questions. Then add a fourth question to the array — the reducer needs no change — and give each question a `topic` label that appears above the prompt.
Read the app once more next week. The interesting thing to notice is not what the code does but how little of it changes when you add a feature — a new case in the reducer, a new branch in the render, and nothing else.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
What is a reducer?
