1. The Problem
Build a to-do list app in the browser where users can add tasks, mark them complete,
and delete them. Tasks persist across page refreshes using localStorage —
so your list survives closing and reopening the browser tab.
Why this project? A to-do list is the "hello world" of stateful apps. It teaches the core CRUD loop (Create, Read, Update, Delete), how to render data as HTML, and how to persist state in the browser without a database.
2. How to Think About This
The key insight: keep the data separate from the display.
Maintain one array of task objects as the single source of truth. Every time the data changes (add/toggle/delete), re-render the entire list from scratch. This pattern — "data changes → render" — is the foundation of every modern UI framework.
tasks array → render() → HTML list user action → update tasks array → render() again
3. The Build
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>To-Do List</title>
<style>
body { font-family: sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
.add-form { display: flex; gap: 8px; margin-bottom: 1.5rem; }
.add-form input { flex: 1; padding: 0.6rem 0.8rem; border: 1px solid #ccc; border-radius: 6px; }
.add-form button { padding: 0.6rem 1rem; background: #2563eb; color: #fff;
border: none; border-radius: 6px; cursor: pointer; }
.task-list { list-style: none; padding: 0; }
.task-item { display: flex; align-items: center; gap: 10px; padding: 0.6rem 0;
border-bottom: 1px solid #eee; }
.task-item input[type=checkbox] { width: 18px; height: 18px; cursor: pointer; }
.task-text { flex: 1; }
.task-text.done { text-decoration: line-through; color: #999; }
.delete-btn { background: none; border: none; color: #dc2626; cursor: pointer;
font-size: 1.1rem; padding: 0 4px; }
.stats { font-size: 0.85rem; color: #666; margin-top: 1rem; }
</style>
</head>
<body>
<h1>📝 To-Do List</h1>
<form class="add-form" id="add-form">
<input type="text" id="task-input" placeholder="Add a task..." required>
<button type="submit">Add</button>
</form>
<ul class="task-list" id="task-list"></ul>
<p class="stats" id="stats"></p>
<script src="todo.js"></script>
</body>
</html>
// ── Data ────────────────────────────────────────────────────────────────────
// Load saved tasks from localStorage, or start with an empty array
let tasks = JSON.parse(localStorage.getItem("tasks") ?? "[]");
// Save tasks to localStorage so they persist across page refreshes
function save() {
localStorage.setItem("tasks", JSON.stringify(tasks));
}
// Create a new task object
function createTask(text) {
return {
id: Date.now(), // unique ID: milliseconds since epoch
text: text.trim(),
completed: false
};
}
// ── UI ───────────────────────────────────────────────────────────────────────
const taskList = document.getElementById("task-list");
const statsEl = document.getElementById("stats");
// Render the current tasks array as HTML
function render() {
if (tasks.length === 0) {
taskList.innerHTML = "<li style='color:#999;padding:0.5rem 0'>No tasks yet. Add one above.</li>";
} else {
// Build HTML for each task, then set it all at once
taskList.innerHTML = tasks.map(task => `
<li class="task-item">
<input type="checkbox" ${task.completed ? "checked" : ""}
onchange="toggle(${task.id})">
<span class="task-text ${task.completed ? "done" : ""}">${escapeHtml(task.text)}</span>
<button class="delete-btn" onclick="remove(${task.id})" aria-label="Delete task">✕</button>
</li>
`).join("");
}
// Update the stats line
const done = tasks.filter(t => t.completed).length;
const total = tasks.length;
statsEl.textContent = total > 0 ? `${done} of ${total} completed` : "";
}
// Escape HTML to prevent XSS when displaying user input
function escapeHtml(str) {
return str.replace(/&/g,"&").replace(/</g,"<")
.replace(/>/g,">").replace(/"/g,""");
}
// ── Actions ──────────────────────────────────────────────────────────────────
function toggle(id) {
tasks = tasks.map(t => t.id === id ? { ...t, completed: !t.completed } : t);
save(); render();
}
function remove(id) {
tasks = tasks.filter(t => t.id !== id);
save(); render();
}
// ── Form handler ─────────────────────────────────────────────────────────────
document.getElementById("add-form").addEventListener("submit", (e) => {
e.preventDefault(); // stop the page from reloading
const input = document.getElementById("task-input");
const text = input.value.trim();
if (!text) return; // ignore empty submissions
tasks = [...tasks, createTask(text)]; // add to front (or back with push)
input.value = ""; // clear the input
save(); render();
});
// Initial render on page load
render();
What each part does
localStorage.getItem/setItem— browser storage that survives page refreshesJSON.parse/stringify— convert between JS objects and the strings localStorage stores?? "[]"— nullish coalescing: use"[]"if nothing is stored yettasks.map(task => `...`).join("")— build HTML string for every task, join into one{ ...t, completed: !t.completed }— spread creates a new object (doesn't mutate original)escapeHtml()— prevent XSS: user input goes to display, never to innerHTML rawe.preventDefault()— stop the form from submitting to a server (default behaviour)
4. Test & Prove
// Pure logic functions (extract from todo.js and test without DOM)
function createTask(text) {
return { id: Date.now(), text: text.trim(), completed: false };
}
function toggle(tasks, id) {
return tasks.map(t => t.id === id ? { ...t, completed: !t.completed } : t);
}
function remove(tasks, id) {
return tasks.filter(t => t.id !== id);
}
let pass = 0, fail = 0;
function test(desc, actual, expected) {
const ok = JSON.stringify(actual) === JSON.stringify(expected) || actual === expected;
ok ? pass++ : fail++;
console.log(ok ? "✓" : "✗", desc);
}
const t1 = createTask(" Buy milk ");
const t2 = createTask("Walk dog");
test("createTask trims whitespace", t1.text, "Buy milk");
test("new task starts incomplete", t1.completed, false);
test("toggle marks task complete", toggle([t1], t1.id)[0].completed, true);
test("toggle twice restores false", toggle(toggle([t1], t1.id), t1.id)[0].completed, false);
test("toggle only affects target task", toggle([t1, t2], t1.id)[1].completed, false);
test("remove deletes correct task", remove([t1, t2], t1.id).length, 1);
test("remove leaves other tasks intact", remove([t1, t2], t1.id)[0].text, "Walk dog");
test("immutability: original unchanged", t1.completed, false); // spread preserved it
console.log(\`
\${pass} passed, \${fail} failed\`);
5. The Interface
Input
A text field and "Add" button. Checkbox to toggle. ✕ button to delete. Submitting an empty field is ignored. All task text is HTML-escaped before display.
Output
A live-updating list of tasks with visual strikethrough for completed items. A stats line ("3 of 5 completed"). Persists across page refreshes via localStorage.
6. Run It
# Open index.html directly in a browser:
open index.html
# Or serve with any local server (needed if you add ES modules):
npx serve . # uses npx — no install needed
# Run the logic tests:
node todo.test.mjs🎯 Try this next
- Filter buttons — show All / Active / Completed; only render matching tasks
- Drag to reorder — use the HTML Drag and Drop API to let users reorder tasks
- Due dates — add a date picker; highlight overdue tasks in red
- Categories — add a tag to each task; filter by tag; store tags as a Set
What you practised
CRUD operations on an array, localStorage for persistence, rendering data as HTML from a template literal,
immutable updates with spread ({ ...task }), XSS prevention with HTML escaping,
and the "data → render" pattern that underpins React, Vue, and every other UI framework.
Reference pages: DOM & Events · Arrays · Objects