React from the ground up

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.

Part 12 of 12

Advanced20 min readBack to Courses

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 done action and a button that sends it.
  • Add an edit action 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.

Live Preview
Console output will appear here...

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.

Live Preview
Console output will appear here...

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?