Project: Interactive To-Do App
A to-do app is the classic first real project because it touches every fundamental of front-end JavaScript at once: you keep state in memory, render the DOM from that state, react to user events, and persist data so it survives a page reload. In this project you’ll build a complete vanilla-JS to-do list — no frameworks, no build step — that lets you add tasks, mark them complete, delete them, filter by status, and remember everything in localStorage. The guiding idea is state-driven rendering: a single array is the source of truth, and the UI is always a function of that array.
The architecture: state in, DOM out
Rather than poking at individual DOM nodes whenever something changes, we keep one array of task objects and re-render the list whenever it mutates. Each task is a small object with a stable id, the text, and a done flag.
let todos = [];
let filter = "all"; // "all" | "active" | "done"
const createTodo = (text) => ({
id: crypto.randomUUID(),
text: text.trim(),
done: false,
});
crypto.randomUUID() is built into modern browsers and gives every task a collision-free identifier — far safer than Date.now() if a user adds several tasks in the same millisecond.
Rendering from state
The render function reads todos, applies the current filter, and rebuilds the list. We attach the task’s id to each element with a data-id attribute so events can later find which task was clicked.
const list = document.querySelector("#list");
const visible = () =>
todos.filter((t) =>
filter === "active" ? !t.done : filter === "done" ? t.done : true
);
const render = () => {
list.innerHTML = visible()
.map(
(t) => `
<li class="row ${t.done ? "done" : ""}" data-id="${t.id}">
<input type="checkbox" ${t.done ? "checked" : ""} data-action="toggle" />
<span>${escapeHtml(t.text)}</span>
<button data-action="delete">✕</button>
</li>`
)
.join("");
save();
};
Always escape user input before inserting it as HTML. A task titled
<img src=x onerror=alert(1)>would otherwise execute. A tinyescapeHtmlhelper that swaps<,>, and&keeps the app XSS-safe.
Event delegation instead of per-row listeners
Re-rendering throws away old DOM nodes, so binding a listener to each checkbox and button would mean re-binding on every render. Instead, attach one listener to the parent <ul> and read data-action from the event target. This pattern — event delegation — scales to thousands of rows for free.
list.addEventListener("click", (e) => {
const row = e.target.closest("[data-id]");
if (!row) return;
const id = row.dataset.id;
const action = e.target.dataset.action;
if (action === "toggle") {
todos = todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
render();
} else if (action === "delete") {
todos = todos.filter((t) => t.id !== id);
render();
}
});
Persisting to localStorage
localStorage only stores strings, so we serialize with JSON.stringify on save and JSON.parse on load. Wrapping the load in a try/catch protects against corrupted data.
const KEY = "todos.v1";
const save = () => localStorage.setItem(KEY, JSON.stringify(todos));
const load = () => {
try {
todos = JSON.parse(localStorage.getItem(KEY)) ?? [];
} catch {
todos = [];
}
};
| Operation | API | Notes |
|---|---|---|
| Save | localStorage.setItem(key, str) | Value must be a string |
| Load | localStorage.getItem(key) | Returns null if missing |
| Remove | localStorage.removeItem(key) | Clears one key |
| Serialize | JSON.stringify(value) | Objects/arrays → string |
| Deserialize | JSON.parse(str) | String → objects/arrays |
The complete app
Here is the entire thing in one self-contained file — markup, styling, and logic. Add a task, check it off, filter, then reload the page: your list is still there.
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: system-ui, sans-serif; max-width: 420px; margin: 2rem auto; }
form { display: flex; gap: .5rem; }
input[type=text] { flex: 1; padding: .5rem; }
ul { list-style: none; padding: 0; }
.row { display: flex; align-items: center; gap: .5rem; padding: .4rem 0; border-bottom: 1px solid #eee; }
.row.done span { text-decoration: line-through; color: #999; }
.row span { flex: 1; }
.filters { display: flex; gap: .5rem; margin: .75rem 0; }
button { cursor: pointer; }
.active-filter { font-weight: bold; }
</style>
</head>
<body>
<h1>To-Do</h1>
<form id="form">
<input id="text" type="text" placeholder="What needs doing?" autocomplete="off" />
<button type="submit">Add</button>
</form>
<div class="filters">
<button data-filter="all" class="active-filter">All</button>
<button data-filter="active">Active</button>
<button data-filter="done">Done</button>
</div>
<ul id="list"></ul>
<script>
let todos = [];
let filter = "all";
const KEY = "todos.v1";
const list = document.querySelector("#list");
const form = document.querySelector("#form");
const input = document.querySelector("#text");
const escapeHtml = (s) =>
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
const save = () => localStorage.setItem(KEY, JSON.stringify(todos));
const load = () => {
try { todos = JSON.parse(localStorage.getItem(KEY)) ?? []; }
catch { todos = []; }
};
const visible = () =>
todos.filter((t) =>
filter === "active" ? !t.done : filter === "done" ? t.done : true
);
const render = () => {
list.innerHTML = visible().map((t) => `
<li class="row ${t.done ? "done" : ""}" data-id="${t.id}">
<input type="checkbox" ${t.done ? "checked" : ""} data-action="toggle" />
<span>${escapeHtml(t.text)}</span>
<button data-action="delete">✕</button>
</li>`).join("");
save();
};
form.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
todos.push({ id: crypto.randomUUID(), text, done: false });
input.value = "";
render();
});
list.addEventListener("click", (e) => {
const row = e.target.closest("[data-id]");
if (!row) return;
const id = row.dataset.id;
const action = e.target.dataset.action;
if (action === "toggle") {
todos = todos.map((t) => t.id === id ? { ...t, done: !t.done } : t);
render();
} else if (action === "delete") {
todos = todos.filter((t) => t.id !== id);
render();
}
});
document.querySelector(".filters").addEventListener("click", (e) => {
const f = e.target.dataset.filter;
if (!f) return;
filter = f;
document.querySelectorAll(".filters button").forEach((b) =>
b.classList.toggle("active-filter", b.dataset.filter === f));
render();
});
load();
render();
</script>
</body>
</html>
Reading back what you stored
If you ever want to inspect the persisted data, it’s just JSON sitting under one key. You can read and summarize it in pure logic:
const raw = '[{"id":"a1","text":"Ship the docs","done":true},{"id":"b2","text":"Write tests","done":false}]';
const todos = JSON.parse(raw);
const remaining = todos.filter((t) => !t.done).length;
console.log(`${todos.length} tasks, ${remaining} remaining`);
console.log(todos.map((t) => `${t.done ? "[x]" : "[ ]"} ${t.text}`).join("\n"));
Output:
2 tasks, 1 remaining
[x] Ship the docs
[ ] Write tests
Best Practices
- Keep a single source of truth (the
todosarray) and derive the DOM from it — never let the UI and your data drift apart. - Use event delegation on a stable parent so dynamically created rows don’t need their own listeners.
- Give every item a stable, unique
id(crypto.randomUUID()) instead of relying on array indices, which shift on delete. - Escape all user-supplied text before injecting it as HTML to prevent XSS.
- Version your storage key (
todos.v1) so you can migrate the shape later without clobbering old data. - Wrap
JSON.parseof stored data intry/catch, and default to an empty array on failure. - Treat state as immutable in handlers (
map/filterreturning new arrays) to keep updates predictable.