TutorialsPreview
Build a Todo App

Build a Todo AppLesson 5

LearnReferencebeginner16 minBuild a Todo App

Persist & filter

Lesson 4 of 4 — survive a reload and filter the view.

You will learn

Persisting state to localStorage (and guarding it), plus deriving a filtered view without mutating your source array.

Prerequisites

Lesson 3 (complete and delete work).

04

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.

project · todo-app · Persist & filter Open in Code
app.jsJAVASCRIPT
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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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();
index.htmlHTML
<!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>
styles.cssCSS
* { 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; }
preview
The finished app. Open in Code, add tasks, reload — they stay.

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

R
Reference·intermediate·17 min

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.

O
Reference·advanced·18 min

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.

S
Reference·beginner·12 min

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 in

No pinned answers yet for this tutorial.