Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Beginner/B17
17 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSB17 · Part 1 · Beginner

Guided project: build a to-do app

Everything from the beginner chapters, in one small thing that works.

5 min read11 sections2 exercises
17 of 41

In this chapter

  • What you are building
  • Step 1 — the markup
  • Step 2 — the state
  • Step 3 — render
  • Step 4 — adding

and 6 more

about 5 min
11On this page
  1. What you are building
  2. Step 1 — the markup
  3. Step 2 — the state
  4. Step 3 — render
  5. Step 4 — adding
  6. Step 5 — toggling and deleting, with one listener
  7. Step 6 — filters
  8. Step 7 — surviving a refresh
  9. Step 8 — what changes with a real server
  10. You are done. Now break it on purpose
  11. Extensions, in increasing difficulty

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

StepCalltasks after
1start[]
2submit "Buy milk"[{id:"t1", title:"Buy milk", done:false}]
3submit "Walk dog"[{id:"t1",...done:false}, {id:"t2", title:"Walk dog", done:false}]
4check the box for t1[{id:"t1",...done:true}, {id:"t2",...done:false}]
5click 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

TryWhat 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 renderHow often you redraw, and that it is fine at this size
Open two tabsThey disagree — the storage event is the fix, and the reason shared state is hard

Extensions, in increasing difficulty

  1. Edit a task on double-click. Watch what happens to focus when you swap the element.
  2. Undo delete — keep the removed task and a timer, and put it back if they click undo.
  3. Reorder by dragging. You will need an order field; sorting the array in place will bite you.
  4. Sync across tabs with window.addEventListener("storage", ...).
  5. Replace localStorage with 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 of tasks.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/load stay the same two function names when swapped for a server, and what becomes async as a result.
  • Name the three things a network-backed save/load has to handle that localStorage never did.

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsTick a to-do off, without mutatingbeginner
  • 8 testsThe filter bar and the items-left countbeginner
All 35 for this level →
B17

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. B16Previous · B16Errors & tools
  2. B17you are here
  3. Next · I1Scope & functions, properlyThe corners of how a scope is built, new versus bind, and what you build once functions are values: currying, composition, recursion.9 min read

01On this page

11 sections · 0%

  1. 01What you are building
  2. 02Step 1 — the markup
  3. 03Step 2 — the state
  4. 04Step 3 — render
  5. 05Step 4 — adding
  6. 06Step 5 — toggling and deleting, with one listener
  7. 07Step 6 — filters
  8. 08Step 7 — surviving a refresh
  9. 09Step 8 — what changes with a real server
  10. 10You are done. Now break it on purpose
  11. 11Extensions, in increasing difficulty

[] chapters · t top