Build a Task Manager
25 minIntermediateReact
In this lesson
- ▸Plan an app's state: what must be stored, and what can be derived
- ▸Add, update and remove items in an array of objects without mutating it
- ▸Combine forms, lists, composition, conditional rendering and effects in one app
The big idea
A real app is the same small ideas, combined: one array of tasks in state, a form that adds to it, a child component that reports clicks up, values derived during render, and one effect that saves it all.
Time to build something you could actually use every day. Before writing code, good developers decide what state the app needs. Ask: "What changes over time, and can I calculate it from something else?"
- tasks (state)
- An array like
[{ id: 1712, title: "Beli susu", done: false }]. The single source of truth. - title (state)
- What's currently typed in the input — a controlled input.
- filter (state)
"all","active"or"done".- visible, remaining (derived)
- Calculated from
tasksandfilterduring render. Not state, no effect.
Updating an array immutably
Never push into state or change a task object in place — React compares references, so it wouldn't notice. Always build a new array:
// add
setTasks([...tasks, { id: Date.now(), title, done: false }]);
// toggle one: copy it with the opposite "done"
setTasks(tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
// delete one
setTasks(tasks.filter((t) => t.id !== id));Date.now() (milliseconds since 1970) is a simple id for a small personal app, as long as you don't create two tasks in the same millisecond. Larger apps use ids from a database or crypto.randomUUID().
Derive, don't duplicate
const visible = tasks.filter((t) =>
filter === "active" ? !t.done : filter === "done" ? t.done : true
);
const remaining = tasks.filter((t) => !t.done).length;Persist with one effect
localStorage only stores strings, so use JSON.stringify to save and JSON.parse to load. Load once by passing a function to useState; save whenever tasks changes.
const [tasks, setTasks] = useState(() => {
try {
return JSON.parse(localStorage.getItem("wancode-tasks")) ?? [];
} catch {
return [];
}
});
useEffect(() => {
localStorage.setItem("wancode-tasks", JSON.stringify(tasks));
}, [tasks]);This lesson's preview storage is kept between runs, so tasks you (or the checks) add will still be there next time — that's persistence working! The checks add tasks with random names and only look at those.
- Keep these names so the checks can find things:
#new-taskinform.add-form,ul.tasksofli.task(add the classdonewhen finished), a checkbox and a.deletebutton in each item, filter buttons withdata-filter="all" | "active" | "done", and.remaining.
Finish handleSubmit: trim the typed title, ignore it if it's empty, otherwise add { id: Date.now(), title, done: false } to the end of tasks and clear the input.
Checks
- Submitting adds the task to the list
- The input is cleared after adding
- Blank tasks are ignored
Check understanding
What you learned
- ✓Plan state first: store the minimum (
tasks,title,filter), derive the rest (visible,remaining). - ✓Update arrays immutably: spread to add,
mapto change one,filterto remove. - ✓Children report events up with callback props; the parent owns the data.
- ✓One effect +
JSON.stringifysaves to localStorage; a lazyuseStateinitialiser loads it back.
Console output from your code appears here.