Skip to content
WANCODE
BM

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 tasks and filter during 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:

JSX
// 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

JSX
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.

JSX
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-task in form.add-form, ul.tasks of li.task (add the class done when finished), a checkbox and a .delete button in each item, filter buttons with data-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

Q1

Why doesn't tasks.push(newTask); setTasks(tasks); update the screen reliably?

Q2

What's the best way to know how many tasks are left?

Q3

Why do we use JSON.stringify before localStorage.setItem?

What you learned

  • Plan state first: store the minimum (tasks, title, filter), derive the rest (visible, remaining).
  • Update arrays immutably: spread to add, map to change one, filter to remove.
  • Children report events up with callback props; the parent owns the data.
  • One effect + JSON.stringify saves to localStorage; a lazy useState initialiser loads it back.
Preview

Console output from your code appears here.

Step 1 of 3