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.targetto see which child. - data attribute
- A custom
data-*attribute on an element, read in JavaScript throughelement.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.parseturns 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.
const KEY = "hectal-tasks";
const state = {
tasks: load(), // [{ id: 1, text: "Buy milk", done: false }, ...]
filter: "all", // "all" | "active" | "done"
};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.
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.
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.
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.
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.
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
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
Add a "Mark all done" button
Add a button with
id="all-done"to the footer, and in the footer listener callupdate(state.tasks.map((t) => ({ ...t, done: true }))). Predict the counter text afterwards. - 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 usingupdate, and decide what happens if the new text is empty.
Code & diagrams
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
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 trueWithout 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
Console
Nothing logged yet.Expected console output
[]
[ { id: 1, text: 'Saved earlier', done: true } ]
string
[]
[]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
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>.
Break #2
Forget to save in one place
Make the delete branch set state.tasks = removeTask(state.tasks, id); render(); instead of calling update.
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>whosedata-idmatches, create or remove only the differences) fixes that; this is exactly what React'skeyprop and Lit'srepeatdirective do. The React course (/react) builds this same app with components next. - ▸
Listen to the
storageevent (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 inload(), 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
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
Updates are pure functions (Topic 15.1):
addTask(tasks, text),toggleTask(tasks, id)andremoveTask(tasks, id)take the old array and return a new one withmap,filterand spread, without mutating. Pure updates are trivial to test in the console (Topic 13.6), and because each returns a new array,old !== newtells 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 usecrypto.randomUUID(). - 3
One render function draws everything from state.
render()builds the visible list withcreateElementandtextContent(neverinnerHTMLwith task text: that would be an XSS hole, Topic 15.4), replaces the old list in one call withreplaceChildren, 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 oneupdate(newTasks)function that sets state, saves and renders: one door in, so nothing is forgotten. - 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 adata-idattribute) says which task, andevent.target.matches(".delete")says which control. New tasks come from a<form>submitevent withpreventDefault(), which gives you Enter-to-submit and works with keyboards and screen readers for free. - 5
**Persistence with
localStorage** (Topic 11.3): save withlocalStorage.setItem(KEY, JSON.stringify(state.tasks))after every change; load once at startup withJSON.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), soload()usestry/catchand 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
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 witharia-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 withfetch(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
Describe the architecture of the task app and why each piece exists.
Why use event delegation on the list instead of a listener per button?
What can go wrong when loading from localStorage, and how does the app handle it?
How would you add syncing to a server without rewriting the app?
Practice
Write a pure function clearDone(tasks) and a stats(tasks) that returns { total, done, left }. Test both.
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.