Add, toggle, delete
All three features are immutable array updates from lesson 4-3, wrapped in handlers. We write them as pure functions that take the current tasks and return a new array, so we can run and test them right here without React:
| Feature | Tool | Shape |
|---|---|---|
| add | spread | [...tasks, newTask] |
| toggle | map | replace one item with a changed copy |
| delete | filter | keep everything except one id |
Inside TaskApp, each handler feeds its helper into the state setter using the functional-update form from lesson 4-2:
function handleAdd(e) { e.preventDefault(); // lesson 6-2 if (text.trim() === "") return; setTasks(ts => addTask(ts, nextId(), text.trim())); setText(""); // clear the controlled input } function handleToggle(id) { setTasks(ts => toggleTask(ts, id)); } function handleDelete(id) { setTasks(ts => deleteTask(ts, id)); }
The three update helpers
addTask appends, toggleTask flips done on one task, and deleteTask removes one id, and all three return new arrays.
function addTask(tasks, nextId, title) { return [...tasks, { id: nextId, title: title, done: false }]; } function toggleTask(tasks, id) { return tasks.map(t => (t.id === id ? { ...t, done: !t.done } : t)); } function deleteTask(tasks, id) { return tasks.filter(t => t.id !== id); } let tasks = []; tasks = addTask(tasks, 1, "Water plants"); tasks = addTask(tasks, 2, "Pay rent"); tasks = toggleTask(tasks, 1); console.log(JSON.stringify(tasks)); tasks = deleteTask(tasks, 2); console.log(JSON.stringify(tasks));
Output
[{"id":1,"title":"Water plants","done":true},{"id":2,"title":"Pay rent","done":false}]
[{"id":1,"title":"Water plants","done":true}]Each helper picks the tool that matches the shape of the change. Adding lengthens the array, so it is a spread, toggling keeps the length and changes one element, so it is map, and deleting shortens it, so it is filter.
toggleTask returns t unchanged for every non-matching task, which is worth noticing. Only the matched task gets a fresh object, so the other tasks keep their identity and React can skip re-rendering their rows, and the array itself is new so React sees the list as changed.
{ ...t, done: !t.done } copies the task and overwrites one field, exactly the pattern from lesson 4-3. Writing t.done = !t.done inside the map would flip the original object and return the same array contents, so React would see nothing new and the screen would not update.
deleteTask uses !== rather than ===, which is the one-character difference between keeping everything else and keeping only the target. Filtering is a keep predicate rather than a remove predicate, and reading it as "keep the tasks whose id is not this one" makes the direction obvious.
Never push, splice, or assign to t.done directly. The rule is to return new arrays and new objects, and these three functions are short enough that following it costs nothing.
Because all three are pure functions of their arguments, they run here with no React at all, which is also what makes them straightforward to test. The handlers inside TaskApp do nothing but feed them to setTasks.
Wiring the children
The leaf components are small. TaskItem renders one task and reports events upward:
function TaskItem({ task, onToggle, onDelete }) { return ( <li> <input type="checkbox" checked={task.done} onChange={() => onToggle(task.id)} /> {task.done ? <s>{task.title}</s> : task.title} <button onClick={() => onDelete(task.id)}>✕</button> </li> ); } function TaskList({ tasks, onToggle, onDelete }) { return ( <ul> {tasks.map(task => ( <TaskItem key={task.id} task={task} onToggle={onToggle} onDelete={onDelete} /> ))} </ul> ); }
Every course concept is on display: props and destructuring (3-1), a controlled checkbox (6-1), a ternary render (5-3), map with a stable key={task.id} (5-1, 5-2), and function props carrying changes up (8-1).
Why the checkbox handler needs an arrow
Without the arrow, onToggle(task.id) runs immediately during render instead of waiting for the click, which is the lesson 4-2 bug.
Event props need a function, and the arrow creates one that will call onToggle(task.id) later, when the event fires. Writing the call directly executes it during render, which updates state mid-render and loops.
The loop is the part that makes this fatal rather than merely wrong. Rendering calls the setter, the setter schedules a render, that render calls the setter again, and React eventually throws a too-many-re-renders error.
| Written as | What React receives |
|---|---|
onChange={() => onToggle(task.id)} | a function to call on the event |
onChange={onToggle(task.id)} | whatever the call returned, usually undefined |
onChange={onToggle} | a function, called with the event object |
The third row is the case worth understanding rather than avoiding. Passing the function bare works when the handler wants the event itself, and here the handler wants an id, so the arrow exists purely to supply that argument.
It is the same rule as onClick={reset} versus onClick={reset()}, and it applies to every event prop in the app. Any time you need to pass an argument to a handler, the arrow is how you delay the call.