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

W
Reference·intermediate·16 min

Write the spec the model can hit

A prompt is a specification. The difference between a junior and a senior prompt is the constraints a senior names that a junior leaves to chance. This lesson turns your architecture sketch into a prompt that produces reviewable code.

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.

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.