Filters, summary, ship it
The filter feature is pure derived state. filter holds "all", "active", or "done", and a helper picks the visible slice during render:
function FilterBar({ filter, onFilterChange }) { return ( <div> {["all", "active", "done"].map(f => ( <button key={f} disabled={f === filter} onClick={() => onFilterChange(f)} > {f} </button> ))} </div> ); }
Even the three buttons come from a map, with the string itself as a perfectly stable key. Disabling the active button is conditional rendering applied to an attribute.
The visible-tasks helper
visibleTasks(tasks, filter) is the last missing piece, and it is a pure function of the two values TaskApp already holds.
const tasks = [ { id: 1, title: "Water plants", done: true }, { id: 2, title: "Pay rent", done: false }, { id: 3, title: "Call mom", done: false }, ]; function visibleTasks(tasks, filter) { if (filter === "active") return tasks.filter(t => !t.done); if (filter === "done") return tasks.filter(t => t.done); return tasks; } console.log(visibleTasks(tasks, "all").length); console.log(visibleTasks(tasks, "active").map(t => t.title).join(", ")); console.log(visibleTasks(tasks, "done").map(t => t.title).join(", "));
Output
3
Pay rent, Call mom
Water plantsTwo if statements and a fallback return handle the three cases, and each if returns immediately so there is no else to write. "Active" means t.done is false, so the predicate is !t.done.
The fallback is doing real work rather than padding the function. It returns tasks itself, not a copy, which is both correct and slightly faster, since the caller only reads the result and never mutates it.
Handling anything unrecognized as "all" is a deliberate choice. A typo in the filter string, or an old value read back from storage, shows every task rather than an empty list, which is the friendlier failure for a list view.
filter | Returned |
|---|---|
"active" | tasks where done is false |
"done" | tasks where done is true |
| anything else | every task |
Note that this function stores nothing and is called during render, which is the whole reason the filter feature needs no synchronization code. Change either input and the next render produces the right list automatically.
The finished app, top to bottom
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; function handleAdd(e) { e.preventDefault(); if (text.trim() === "") return; setTasks(ts => [...ts, { id: Date.now(), title: text.trim(), done: false }]); setText(""); } return ( <div> <h1>Tasks</h1> <form onSubmit={handleAdd}> <input value={text} onChange={e => setText(e.target.value)} /> <button>Add</button> </form> <FilterBar filter={filter} onFilterChange={setFilter} /> <TaskList tasks={visible} onToggle={id => setTasks(ts => toggleTask(ts, id))} onDelete={id => setTasks(ts => deleteTask(ts, id))} /> <p>{left === 0 ? "All done!" : left + " task(s) left"}</p> </div> ); }
Read it slowly, you can now account for every line: three pieces of minimal state, two derived values, a controlled form, pure update helpers behind functional setters, filtered list rendering with stable keys, and a conditional summary. That is a complete, correct React application.
Unchecking the last done task while viewing "done"
setTasks triggers a re-render, visibleTasks recomputes to an empty list for "done", and the list empties automatically.
This is the payoff of derived state. The toggle updates tasks, React re-renders, and every derived value, meaning visible and left, recomputes from the fresh state.
So the item vanishes from the done view and the summary updates in the same render, with zero synchronization code. Nothing in the app was written to handle "the currently visible item stopped matching the filter", and it works because that case was never special to begin with.
Worth noticing what the user sees, which is a row disappearing the moment they uncheck it. That is correct behavior for a filtered view and can feel abrupt, and softening it would be an animation decision rather than a state one.
The alternative design shows why derivation is worth the discipline. If visible were state, this exact scenario is the bug report you would get first, because the toggle handler would update tasks and leave the stale row on screen.
The idea underneath all of it
The task tracker never calls document.getElementById or textContent, and the screen always matches the data, because of UI as a function of data.
That is UI = f(data), the declarative rendering idea from lesson 1-2. Components describe the screen for the current state, and React recomputes and patches the DOM whenever state changes.
Every feature in this unit worked that way. Filters, toggles, deletes, and the summary were all built by changing data and letting the render loop do the rest, and none of them contained a line that touched an element.
The contrast with unit 1's imperative version is the measure of what you gained. There, each feature meant writing the update path for every affected element by hand, and the number of those paths grew faster than the number of features.
That is React, and everything else in the ecosystem, including routers, data libraries, and frameworks, is built on top of this one equation.