Persist & filter
Lesson 4 of 4 — survive a reload and filter the view.
Persisting state to localStorage (and guarding it), plus deriving a filtered view without mutating your source array.
Prerequisites
Lesson 3 (complete and delete work).
Step 416 min
Save it, and filter it
GoalThe list survives a reload, and All / Active / Done switch what you see.
Two finishing moves. First, save() writes the array to localStorage after every change and load() reads it on start. Second, a visible() helper returns a filtered copy so render() can draw All, Active, or Done without ever mutating the real list.
const KEY = 'todos:v1';const list = document.getElementById('list');const form = document.getElementById('new-todo');const input = document.getElementById('title');const count = document.getElementById('count');const filters = document.querySelectorAll('[data-filter]'); let todos = load();let filter = 'all'; form.addEventListener('submit', (e) => { e.preventDefault(); const title = input.value.trim(); if (!title) return; todos.push({ id: Date.now(), title, done: false }); input.value = ''; save(); render();}); list.addEventListener('click', (e) => { const li = e.target.closest('li'); if (!li) return; const id = Number(li.dataset.id); if (e.target.matches('.remove')) { todos = todos.filter((t) => t.id !== id); } else { const t = todos.find((t) => t.id === id); if (t) t.done = !t.done; } save(); render();}); filters.forEach((btn) => btn.addEventListener('click', () => { filter = btn.dataset.filter; filters.forEach((b) => b.classList.toggle('active', b === btn)); render(); }),); function visible() { if (filter === 'active') return todos.filter((t) => !t.done); if (filter === 'done') return todos.filter((t) => t.done); return todos;} function render() { list.innerHTML = ''; for (const t of visible()) { const li = document.createElement('li'); li.dataset.id = t.id; li.className = t.done ? 'done' : ''; li.innerHTML = '<span class="check"></span>' + '<span class="title">' + esc(t.title) + '</span>' + '<button class="remove" aria-label="Delete">\u00d7</button>'; list.appendChild(li); } const left = todos.filter((t) => !t.done).length; count.textContent = left + ' left';} function esc(s) { return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c], );} // Guarded so the sandboxed preview (no same-origin storage) falls back to// in-memory instead of throwing; in /code and in production it persists.function load() { try { return JSON.parse(localStorage.getItem(KEY)) || []; } catch { return []; }} function save() { try { localStorage.setItem(KEY, JSON.stringify(todos)); } catch { /* sandbox preview — persistence unavailable, keep going */ }} render();<!doctype html><html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Todo</title> <link rel="stylesheet" href="styles.css" /> </head> <body> <main class="app"> <h1>Today</h1> <form id="new-todo"> <input id="title" name="title" placeholder="What needs doing?" autocomplete="off" /> <button type="submit">Add</button> </form> <ul id="list" class="list"></ul> <footer class="bar"> <span id="count">1 left</span> <span class="filters"> <button data-filter="all" class="active">All</button> <button data-filter="active">Active</button> <button data-filter="done">Done</button> </span> </footer> </main> <script src="app.js"></script> </body></html>* { box-sizing: border-box; }body { margin: 0; min-height: 100vh; display: grid; place-items: start center; padding: 6vh 16px; font-family: system-ui, -apple-system, sans-serif; background: #0b0e14; color: #e7ecf3;}.app { width: 100%; max-width: 460px; }h1 { font-size: 22px; letter-spacing: -0.01em; margin: 0 0 16px; }#new-todo { display: flex; gap: 8px; margin-bottom: 18px; }#title { flex: 1; padding: 11px 14px; border-radius: 10px; border: 1px solid #232a36; background: #11151c; color: inherit; font-size: 15px;}#title:focus { outline: none; border-color: #6ea8fe; }#new-todo button { padding: 0 18px; border-radius: 10px; border: 1px solid #2b3340; background: #6ea8fe; color: #0b0e14; font-weight: 600; cursor: pointer;}.list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }.list li { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 10px; background: #11151c; border: 1px solid #1c2330;}.check { width: 18px; height: 18px; flex: none; border-radius: 999px; border: 1.5px solid #3a4456; cursor: pointer;}.list li.done .check { background: #4ade80; border-color: #4ade80; }.list li.done .title { color: #5b6675; text-decoration: line-through; }.title { flex: 1; }.remove { border: none; background: none; color: #5b6675; font-size: 20px; line-height: 1; cursor: pointer;}.remove:hover { color: #f87171; }.bar { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; font-size: 13px; color: #8b93a3;}.filters { display: flex; gap: 4px; }.filters button { border: 1px solid transparent; background: none; color: inherit; padding: 4px 10px; border-radius: 8px; cursor: pointer;}.filters button.active { border-color: #2b3340; color: #e7ecf3; }Milestone· closes the promise from step 1
You built a todo app
Structure, behaviour, interaction, persistence — the exact app promised in lesson 1, and every line is yours. This is the whole shape of front-end work in miniature: hold state, render from it, react to events, save it.
You are done when
Series
Next: Build with AI
Now build the next feature by directing a coding agent — the senior-engineer loop.
Concepts covered · 2
More like this
Review what comes back like a senior
Models produce confident, plausible, well-formatted code — which is exactly what makes their mistakes dangerous. This lesson is the review rubric: the specific failure classes to hunt for, because "it looks right" is not review.
Orchestrate agents without the codebase rotting
Putting it together: context, guardrails, and a verification loop that lets you run coding agents across a real codebase without it drifting into an incoherent pile. This is the senior loop at full speed.
Structure & style the app
Lay down the HTML skeleton and a calm dark style for a todo app. No JavaScript yet — just the shape, so the behaviour you add next has somewhere to live.
Liked this one?
Pass it on
Discussion
Be the first to ask
Your questions stay private with the team. We can pin answers to share with everyone.
Sign in to ask a question privately on this tutorial.
Sign inNo pinned answers yet for this tutorial.