Command Palette

Search for a command to run...

Hectal
PHASE 15Advanced ~27 min· topic 8 of 8

Topic 15.8

Capstone: Build a Task App

In one line

The capstone builds a complete task app in plain JavaScript with one clear shape: a single state object, pure functions that compute new state, one render function that draws the page from state, delegated event listeners, and localStorage so tasks survive a reload.

Think of it like this

A scoreboard at a cricket match. There is one official score book (the state). When something happens, the scorer writes a new line in the book (an update). The big board never decides anything itself; it only shows what the book says (the render). At the end of the day the book is locked in a cupboard (saved to storage) and opened again tomorrow (loaded). If the board and the book ever disagree, the book wins.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

State
All the data your app needs right now, kept in one place.
Single source of truth
The rule that each piece of data lives in exactly one place, and everything else is computed or drawn from it.
Derived data
Values computed from state when needed, like "how many tasks are left", instead of stored separately.
Render
Draw the page from the current state.
Event delegation
One listener on a parent handling events from all of its children, using event.target to see which child.
data attribute
A custom data-* attribute on an element, read in JavaScript through element.dataset.
Persistence
Keeping data after the page closes, here by saving it in localStorage.
Serialise
Turn data into a string to store or send it, here with JSON.stringify; JSON.parse turns it back.

Step by step

01Step 1: design the state

Write the shape down before any code. Everything the screen shows must be computable from this object.

app.jswhole filejavascript
const KEY = "hectal-tasks";
const state = {
  tasks: load(),   // [{ id: 1, text: "Buy milk", done: false }, ...]
  filter: "all",   // "all" | "active" | "done"
};
Step 1: design the statediagram
Rendering diagram…

02Step 2: write the pure updates

Each update takes the old tasks and returns new ones. Test them in the console before any HTML exists.

app.jswhole filejavascript
const nextId = (tasks) => tasks.reduce((max, t) => Math.max(max, t.id), 0) + 1;
const addTask = (tasks, text) => [...tasks, { id: nextId(tasks), text, done: false }];
const toggleTask = (tasks, id) => tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
const removeTask = (tasks, id) => tasks.filter((t) => t.id !== id);
const visible = (tasks, filter) =>
  tasks.filter((t) => filter === "all" || (filter === "done") === t.done);

03Step 3: render from state

Build each <li> with safe DOM APIs and put the task id in data-id. Replace the whole list at once, then update the counter.

app.jswhole filejavascript
function render() {
  list.replaceChildren(...visible(state.tasks, state.filter).map(taskItem));
  const left = state.tasks.filter((t) => !t.done).length;
  count.textContent = left + (left === 1 ? " task" : " tasks") + " left";
}

04Step 4: one door for every change

update is the only function that changes state.tasks. It saves and re-renders every time, so the page and storage can never fall behind.

app.jswhole filejavascript
function update(tasks) {
  state.tasks = tasks;
  save();
  render();
}

05Step 5: wire events with delegation

The form handles adding. One listener on the list handles toggling and deleting for every item, including items added later.

app.jswhole filejavascript
list.addEventListener("click", (e) => {
  const li = e.target.closest("li");
  if (!li) return;
  const id = Number(li.dataset.id);
  if (e.target.matches(".toggle")) update(toggleTask(state.tasks, id));
  if (e.target.matches(".delete")) update(removeTask(state.tasks, id));
});

06Step 6: save and load safely

Save after every change. Load once, and survive missing or broken data.

app.jswhole filejavascript
function load() {
  try {
    const data = JSON.parse(localStorage.getItem(KEY));
    return Array.isArray(data) ? data : [];
  } catch {
    return [];
  }
}
const save = () => localStorage.setItem(KEY, JSON.stringify(state.tasks));

07Step 7: filters, clear done and polish

Filters only change state.filter and re-render: no data changes, so no save is needed. "Clear done" is one more pure update. Finish with accessible names, aria-pressed on filters and an empty-state message.

Try it yourself

  1. 1

    Start with saved tasks

    In the finished app, add localStorage.setItem("hectal-tasks", JSON.stringify([{ id: 7, text: "From last time", done: false }])); as the very first line. Predict the first console line and the id of "Buy milk", then run.

  2. 2

    Add a "Mark all done" button

    Add a button with id="all-done" to the footer, and in the footer listener call update(state.tasks.map((t) => ({ ...t, done: true }))). Predict the counter text afterwards.

  3. 3

    Edit on double-click

    Make double-clicking a task's text replace the span with an input; on Enter, save with a new pure function editTask(tasks, id, text). Keep using update, and decide what happens if the new text is empty.

Code & diagrams

Steps 1 and 2: state and pure updates, tested in the console New tab

Every update returned a new array and left the old one alone. After deleting task 1 the next id is 3, so ids are never reused while the list exists.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

[ { id: 1, text: 'Buy milk', done: true }, { id: 2, text: 'Write tests', done: false } ]
[ 'Write tests' ] [ 'Buy milk' ]
[ { id: 2, text: 'Write tests', done: false } ] 3
false false true
Step 6: loading that survives missing and broken data New tab

Without the `try/catch`, the broken value would throw `SyntaxError` at startup and the whole app would fail to load. Without the `Array.isArray` check, the wrong shape would crash later in `render`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

[]
[ { id: 1, text: 'Saved earlier', done: true } ]
string
[]
[]
The finished task app New tab

The blank task was ignored. Use the app yourself: add, tick, delete, filter, clear done. Every change goes through `update`, so the page and storage always match the state.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

2 tasks left
[{"id":1,"text":"Buy milk","done":false},{"id":2,"text":"Write tests","done":true},{"id":3,"text":"Call Asha","done":false}]
[ 'Buy milk', 'Call Asha' ]

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Render task text with innerHTML

In taskItem, replace text.textContent = t.text; with text.innerHTML = t.text; and add a task named <i>urgent</i>.

terminal
$ Page
── what you'll see ──
The task shows as italic "urgent" instead of the literal text, and the `<i>` element is now part of your page.

Break #2

Forget to save in one place

Make the delete branch set state.tasks = removeTask(state.tasks, id); render(); instead of calling update.

terminal
$ Page after reload
── what you'll see ──
Deleted tasks come back after a reload: the page changed but localStorage still holds the old list.

Myth vs fact

Myth

You need a framework to build a real app.

Fact

State, pure updates, a render function and delegated events are the same architecture frameworks use. Frameworks add efficient DOM updates, components and tooling, which matter as apps grow.

Myth

Re-rendering the whole list is too slow.

Fact

For hundreds of items it takes well under a frame. Measure first (Topic 15.3); reach for keyed updates or virtual lists only when profiling shows a problem.

Myth

localStorage is a database.

Fact

It is a small (about 5 MB per origin), synchronous, string-only store, per browser and per device. Use IndexedDB for larger data and a server for anything shared or important.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Rebuilding the list loses focus and scroll position inside it. A keyed update (reuse the <li> whose data-id matches, create or remove only the differences) fixes that; this is exactly what React's key prop and Lit's repeat directive do. The React course (/react) builds this same app with components next.

  • ▸

    Listen to the storage event (window.addEventListener("storage", ...)) to sync state when the same app is open in another tab; it fires in other tabs, not the one that wrote.

  • ▸

    Version your stored data: save { version: 2, tasks } and migrate older shapes in load(), so an app update never crashes on a returning user's old data.

  • ▸

    For larger apps, the same pattern becomes a reducer: state = reducer(state, { type: "toggle", id }). Logging every action gives you undo, time-travel debugging and easy tests.

Remember this

  1. 1

    State is the single source of truth. All the data the app needs lives in one object: { tasks: [...], filter: "all" }. Each task is { id, text, done }. The page is never the place you read data from (no "count the <li> elements to know how many tasks there are"); it is only a picture of the state. This one rule removes a whole family of bugs where the page and the data drift apart. Derived data (how many are left, which are visible under the filter) is computed from state each time, never stored separately.

  2. 2

    Updates are pure functions (Topic 15.1): addTask(tasks, text), toggleTask(tasks, id) and removeTask(tasks, id) take the old array and return a new one with map, filter and spread, without mutating. Pure updates are trivial to test in the console (Topic 13.6), and because each returns a new array, old !== new tells you something changed. IDs come from the largest existing id plus one: stable, unique within the list, and never reused while the list exists. Real apps often use crypto.randomUUID().

  3. 3

    One render function draws everything from state. render() builds the visible list with createElement and textContent (never innerHTML with task text: that would be an XSS hole, Topic 15.4), replaces the old list in one call with replaceChildren, and updates the counter and filter buttons. Re-rendering the whole list is simple and fast enough for hundreds of items; frameworks like React do the same conceptually but compare old and new output to touch only what changed. Every change goes through one update(newTasks) function that sets state, saves and renders: one door in, so nothing is forgotten.

  4. 4

    Events use delegation (Phase 9): instead of adding a listener to every checkbox and delete button (which would need re-adding after every render), one listener on the <ul> handles clicks for all items, present and future. event.target.closest("li") finds the item, li.dataset.id (from a data-id attribute) says which task, and event.target.matches(".delete") says which control. New tasks come from a <form> submit event with preventDefault(), which gives you Enter-to-submit and works with keyboards and screen readers for free.

  5. 5

    **Persistence with localStorage** (Topic 11.3): save with localStorage.setItem(KEY, JSON.stringify(state.tasks)) after every change; load once at startup with JSON.parse. Storage only holds strings, the data can be missing (first visit), corrupted (edited by hand, an old version of your app) or unavailable (some private modes throw), so load() uses try/catch and checks the shape, falling back to []. Use a namespaced key like "hectal-tasks" so apps on the same origin don't collide, and never store secrets there. In Hectal's preview, storage lasts only until the next Run.

  6. 6

    Polish that separates a demo from a product: trim input and ignore empty tasks; keep focus in the input after adding; give icon-like buttons accessible names (aria-label="Delete Buy milk"); mark the active filter with aria-pressed; show a friendly empty state; and keep the counter grammatical ("1 task left", "2 tasks left"). From here, natural next steps are editing a task, drag-to-reorder, syncing to a server with fetch (Topic 11.2) and turning the item into a Web Component (Topic 15.5). The phase's last two exercises build this whole app with automatic checks.

Explain it without notes

01

Describe the architecture of the task app and why each piece exists.

02

Why use event delegation on the list instead of a listener per button?

03

What can go wrong when loading from localStorage, and how does the app handle it?

04

How would you add syncing to a server without rewriting the app?

Practice

01

Write a pure function clearDone(tasks) and a stats(tasks) that returns { total, done, left }. Test both.

02

Write editTask(tasks, id, text) that trims the text, removes the task if the text is empty, and otherwise updates its text immutably.

Trade-offs

  • ↔

    Re-rendering everything from state is simple and bug-resistant, but it loses focus inside the list and does more DOM work than needed for very large lists; keyed updates fix both at the cost of more code.

  • ↔

    Saving to localStorage after every change is easy and instant, but it is per device and synchronous; a server or IndexedDB adds sync and capacity at the cost of async code and error handling.

Done when you can

  • I can design a state object and keep it as the single source of truth.

  • I can write pure add, toggle, remove and filter functions.

  • I can render a list from state safely with DOM APIs.

  • I can handle all item clicks with one delegated listener.

  • I can save to and load from localStorage, surviving missing and broken data.

  • I can build the whole task app from an empty file.