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

A
Reference·beginner·15 min

Add todos with JavaScript

Wire the form so typing and pressing Add creates a real item. You will hold the todos in an array and render the list from it — the pattern every UI framework formalises later.

C
Reference·beginner·14 min

Complete & delete

Click a row to toggle it done; click the × to delete it. You will do it with a single listener on the list — event delegation — instead of one per row.

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.

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.