B17 · Part 1 · Beginner
Guided project: build a to-do app
Everything from the beginner chapters, in one small thing that works.
11On this page
What you are building
A list you can add to, tick off, filter and delete, that survives a refresh. No framework, no build step — one HTML file, one CSS file, one JavaScript file. It uses every beginner chapter: types, flow, functions, arrays, destructuring, the DOM, events, and storage.
Build it in order. Each step ends with something that runs, so you always know which change broke it.
Step 1 — the markup
<form id="new">
<label for="title">New task</label>
<input id="title" name="title" required autocomplete="off">
<button>Add</button>
</form>
<div id="filters">
<button data-filter="all" aria-pressed="true">All</button>
<button data-filter="open" aria-pressed="false">Open</button>
<button data-filter="done" aria-pressed="false">Done</button>
</div>
<ul id="list"></ul>
<p id="count"></p>
A real <form> means Enter submits for free. A real
<label> means the input has a name. Both are things you get
by using the right element rather than by writing JavaScript.
Step 2 — the state
let tasks = []; // [{ id, title, done }]
let filter = "all";
function makeTask(title) {
return { id: crypto.randomUUID(), title: title.trim(), done: false };
}
One rule that makes the rest easy
The array is the truth; the DOM is a picture of it. Never read state back out of the page — change the array, then redraw. Almost every bug in a first app comes from letting the two disagree.
Step 3 — render
const list = document.querySelector("#list");
function visible() {
if (filter === "open") return tasks.filter((t) => !t.done);
if (filter === "done") return tasks.filter((t) => t.done);
return tasks;
}
function render() {
const rows = visible();
list.replaceChildren(); // clear
for (const { id, title, done } of rows) { // destructuring
const li = document.createElement("li");
const box = document.createElement("input");
box.type = "checkbox";
box.checked = done;
box.dataset.id = id;
const span = document.createElement("span");
span.textContent = title; // textContent, never innerHTML
const del = document.createElement("button");
del.textContent = "Delete";
del.dataset.id = id;
li.append(box, span, del);
list.append(li);
}
document.querySelector("#count").textContent =
tasks.filter((t) => !t.done).length + " left";
}
textContent rather than innerHTML is the whole XSS
defence here. Type <img src=x onerror=alert(1)> as a task
title and it appears as text, which is what you want.
Step 4 — adding
document.querySelector("#new").addEventListener("submit", (e) => {
e.preventDefault(); // or the page reloads
const input = document.querySelector("#title");
const title = input.value.trim();
if (!title) return;
tasks = [...tasks, makeTask(title)]; // new array, not push
input.value = "";
input.focus(); // ready for the next one
save();
render();
});
Step 5 — toggling and deleting, with one listener
list.addEventListener("click", (e) => {
const id = e.target.dataset.id;
if (!id) return; // clicked the gap
if (e.target.type === "checkbox") {
tasks = tasks.map((t) => (t.id === id ? { ...t, done: e.target.checked } : t));
} else if (e.target.tagName === "BUTTON") {
tasks = tasks.filter((t) => t.id !== id);
}
save();
render();
});
One listener on the list rather than two per row. This is event delegation, and it is why rows added later still work without wiring anything up.
Dry run: tasks through add, toggle, delete
| Step | Call | tasks after |
|---|---|---|
| 1 | start | [] |
| 2 | submit "Buy milk" | [{id:"t1", title:"Buy milk", done:false}] |
| 3 | submit "Walk dog" | [{id:"t1",...done:false}, {id:"t2", title:"Walk dog", done:false}] |
| 4 | check the box for t1 | [{id:"t1",...done:true}, {id:"t2",...done:false}] |
| 5 | click Delete on t2 | [{id:"t1", title:"Buy milk", done:true}] |
Every step replaces tasks with a whole new array —
[...tasks, makeTask(title)],
tasks.map(...), tasks.filter(...) — never
mutates the old one in place. That's what "the array is the truth"
means in practice: render() can always trust that a
changed reference means changed data, because nothing ever edits the
old array behind its back.
Step 6 — filters
document.querySelector("#filters").addEventListener("click", (e) => {
const next = e.target.dataset.filter;
if (!next) return;
filter = next;
for (const b of e.currentTarget.children) {
b.setAttribute("aria-pressed", String(b.dataset.filter === filter));
}
render();
});
aria-pressed is what tells a screen reader which filter is active.
Colour alone does not.
Step 7 — surviving a refresh
const KEY = "todo:v1";
function save() {
try {
localStorage.setItem(KEY, JSON.stringify(tasks));
} catch {} // private mode, quota
}
function load() {
try {
const raw = localStorage.getItem(KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : []; // don't trust storage
} catch {
return [];
}
}
tasks = load();
render();
Both accesses are wrapped. localStorage throws in private browsing
on some browsers and when the quota is full; and whatever is in there was
written by an older version of your own code, so validate its shape before
trusting it. The :v1 in the key is how you change that shape later
without breaking existing users.
Step 8 — what changes with a real server
Extension 5 below asks you to replace localStorage with
a server. The shape of save/load stays the
same — two functions the rest of the app already calls —
but both become async, and both can now fail for reasons
that have nothing to do with your code.
async function load() {
try {
const response = await fetch("/api/tasks");
if (!response.ok) throw new Error("server said " + response.status);
const data = await response.json();
return Array.isArray(data) ? data : [];
} catch {
return []; // offline, server down, bad response — same fallback either way
}
}
async function save(tasksToSave) {
try {
await fetch("/api/tasks", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(tasksToSave),
});
} catch {
// a real app queues this and retries — silently losing a save is worse than a slow one
}
}
// the call site barely changes:
tasks = await load(); // was: tasks = load();
render();
Three things that were invisible with localStorage
become unavoidable the moment a network sits in between: a
loading state (the list is empty for a moment while
load() is still in flight — worth a "Loading…" row
instead of an empty list that looks broken), an error state
(the request can fail in ways a synchronous localStorage
call never could — offline, a 500, a timeout), and the exact
out-of-order-save race extension 5 mentions: two
save() calls fired close together can have their
responses arrive in the reverse order they were sent, so the
older save can silently overwrite the newer one on the
server. The stale-response
pattern — give every save its own ticket number, ignore the
response if a newer save has already started — is the fix, and
it's the same shape either way.
You are done. Now break it on purpose
| Try | What it teaches |
|---|---|
Add a task called <b>hi</b> | Why textContent matters |
Swap [...tasks, x] for tasks.push(x) | It still works — and why the copy is a habit worth keeping |
Remove e.preventDefault() | The default action a form has |
Put console.log(tasks) in render | How often you redraw, and that it is fine at this size |
| Open two tabs | They disagree — the storage event is the fix, and the reason shared state is hard |
Extensions, in increasing difficulty
- Edit a task on double-click. Watch what happens to focus when you swap the element.
- Undo delete — keep the removed task and a timer, and put it back if they click undo.
- Reorder by dragging. You will need an
orderfield; sorting the array in place will bite you. - Sync across tabs with
window.addEventListener("storage", ...). - Replace
localStoragewith a server. Now you meet loading states, errors, and the race where two saves land out of order — which is where async stops being theory.
Why this project and not a bigger one
It is small enough to finish in an evening and large enough to contain the four things every app has: state, a way to change it, a way to draw it, and somewhere to keep it. Every framework you learn later is a different answer to those same four — and you will understand the answers better for having written the manual version once.
Before you move on
- Explain the one rule ("the array is the truth") and point to the exact line in
render()that depends on it. - Say why
tasks = [...tasks, makeTask(title)]is written that way instead oftasks.push(makeTask(title)). - Trace the task array through an add, a toggle, and a delete, writing down its shape after each one.
- Explain why
save/loadstay the same two function names when swapped for a server, and what becomesasyncas a result. - Name the three things a network-backed
save/loadhas to handle thatlocalStoragenever did.
Opens in the editor: write it, run it, and check it against real tests.
All 35 for this level →Finished reading?
0 of 41 chapters read in this series