Course outline · 0% complete

0/28 lessons0%

Course overview →

Designing the task tracker

lesson 9-1 · ~11 min · 25/28

Step 3 of the recipe

Lesson 8-3 laid out five design steps, and step 3, right after building the static version, is to find the minimal set of state, dropping anything computable from other data.

The procedure is to list all the data on screen and then cross out whatever is passed in as props, never changes, or can be derived. What survives all three tests is state, and it is usually a much shorter list than the first draft suggests.

We are about to apply the full recipe to a real app, so the five steps stop being a checklist and become the order the code gets written in.

Designing the task tracker

We finish the course by building a complete task tracker, the app you have been circling since unit 2. Features:

  • add a task by typing and submitting a form
  • toggle a task done or not done
  • delete a task
  • filter buttons: All, Active, Done
  • a remaining-count summary

Step 1, the hierarchy:

TaskApp
├── NewTaskForm    (controlled input + submit)
├── FilterBar      (All / Active / Done buttons)
├── TaskList       (the visible tasks)
│   └── TaskItem   (checkbox, title, delete button)
└── Summary        ("2 task(s) left")

Step 3, minimal state. Candidates: the tasks, the filter choice, the input text, the visible tasks, the remaining count. Apply the tests from lesson 8-3:

  • tasks: changes, not computable → state
  • filter: changes, not computable → state
  • text (the input draft): changes, not computable → state (controlled input, lesson 6-1)
  • visible tasks → derived from tasks + filter
  • remaining count → derived from tasks (lesson 8-2)

Step 4, placement. Every consumer meets at TaskApp, so all three pieces of state live there.

The skeleton

function TaskApp() {
  const [tasks, setTasks] = useState([]);
  const [filter, setFilter] = useState("all");
  const [text, setText] = useState("");

  const visible = visibleTasks(tasks, filter);
  const left = tasks.filter(t => !t.done).length;

  return (
    <div>
      <h1>Tasks</h1>
      <NewTaskForm text={text} onTextChange={setText} onAdd={handleAdd} />
      <FilterBar filter={filter} onFilterChange={setFilter} />
      <TaskList tasks={visible} onToggle={handleToggle} onDelete={handleDelete} />
      <Summary tasks={tasks} />
    </div>
  );
}

Each task is an object: { id: 1, title: "Water plants", done: false }, the shape from lesson 5-1, with id serving as the key from lesson 5-2. Data flows down as props, changes flow up through onAdd, onToggle, onDelete, onFilterChange, and onTextChange, the lifted-state pattern from lesson 8-1. The handlers are what we build next.

Why the remaining count is not state

It is derived, since tasks.filter(t => !t.done).length recomputes it on every render, so storing it would duplicate truth.

This is lesson 8-2's rule applied to the capstone. The count is fully determined by tasks, so it is computed during render and cannot disagree with the list beneath it.

If it were separate state, every add, toggle, and delete handler would have to remember to update it too. That is three handlers today and every future one forever, and the first one that forgets produces a header that contradicts the visible rows.

The same test disqualifies the visible task list, which derives from tasks plus filter. Two of the five candidates from step 3 were derived, which is a typical ratio and the reason the step is worth doing explicitly.

Note that filter and text survive the test for the same reason as each other. Neither can be worked out from the tasks, since the selected tab and the half-typed draft are facts about what the user is doing rather than facts about the data.

How TaskItem reports a delete

The prop is onDelete, and the path is worth tracing in full.

TaskApp passes onDelete={handleDelete} into TaskList, which hands it to each TaskItem. The item calls onDelete(task.id), and the parent, as owner of the tasks state, performs the update.

That is the same inverse data flow as lesson 8-1, with one extra hop because TaskList sits between the owner and the item. TaskList neither reads nor changes tasks state, it only forwards the callback, which is the mildest form of prop drilling.

The argument is the id rather than the whole task or an index, and that choice matters. An id identifies the task no matter how the array has been filtered or reordered since render, while an index is only meaningful against one particular array.

DirectionCarried asExample here
downprop valuestasks={visible}
upfunction callsonDelete(task.id)

Note that TaskItem does not know whether deleting removes the task, archives it, or asks for confirmation first. It reports an intention, and handleDelete decides what that intention means, which is what keeps the item reusable.