This is the project that teaches the pattern behind every front-end app: keep your data in one place, and rebuild the page from it whenever it changes.
The HTML#
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>To-do</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="app">
<h1>To-do</h1>
<form id="add-form">
<input id="new-task" type="text" placeholder="What needs doing?" required>
<button type="submit">Add</button>
</form>
<p id="count"></p>
<ul id="list"></ul>
</main>
<script src="app.js" defer></script>
</body>
</html>defer means the script runs after the HTML is parsed, so the elements exist when your code looks for them. Without it you get “Cannot set properties of null”.
The CSS#
body {
font-family: system-ui, sans-serif;
background: #f7f8fb;
margin: 0;
padding: 40px 20px;
}
.app {
max-width: 480px;
margin: 0 auto;
background: #fff;
padding: 24px;
border-radius: 14px;
box-shadow: 0 4px 16px rgba(0, 0, 0, .06);
}
#add-form { display: flex; gap: 8px; }
#new-task { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 8px; }
#list { list-style: none; padding: 0; }
#list li {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 4px;
border-bottom: 1px solid #eee;
}
#list li.done span { text-decoration: line-through; opacity: .55; }
.delete { margin-left: auto; border: 0; background: none; cursor: pointer; }Step 1: state and rendering#
Keep the data in an array. Never read the truth back out of the page.
let tasks = [];
const list = document.querySelector("#list");
const count = document.querySelector("#count");
function render() {
list.innerHTML = "";
for (const task of tasks) {
const li = document.createElement("li");
li.dataset.id = task.id;
if (task.done) li.classList.add("done");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = task.done;
const label = document.createElement("span");
label.textContent = task.text; // safe: not parsed as HTML
const remove = document.createElement("button");
remove.className = "delete";
remove.textContent = "×";
remove.setAttribute("aria-label", "Delete " + task.text);
li.append(checkbox, label, remove);
list.appendChild(li);
}
const done = tasks.filter(t => t.done).length;
count.textContent = tasks.length ? `${done} of ${tasks.length} done` : "Nothing yet.";
}Step 2: adding a task#
const form = document.querySelector("#add-form");
const input = document.querySelector("#new-task");
form.addEventListener("submit", (event) => {
event.preventDefault(); // stop the page reloading
const text = input.value.trim();
if (!text) return;
tasks.push({ id: Date.now(), text, done: false });
input.value = "";
save();
render();
});Step 3: one listener for every row#
list.addEventListener("click", (event) => {
const li = event.target.closest("li");
if (!li) return;
const id = Number(li.dataset.id);
if (event.target.matches(".delete")) {
tasks = tasks.filter(t => t.id !== id);
} else if (event.target.matches("input[type=checkbox]")) {
const task = tasks.find(t => t.id === id);
task.done = !task.done;
} else {
return;
}
save();
render();
});One listener handles every row, including rows added later. Attaching a listener to each button individually would break the moment you add a new task. See JavaScript events explained.
Step 4: making it persist#
const KEY = "todo-tasks-v1";
function save() {
localStorage.setItem(KEY, JSON.stringify(tasks));
}
function load() {
try {
tasks = JSON.parse(localStorage.getItem(KEY)) || [];
} catch {
tasks = [];
}
}
load();
render();localStorage only stores text, which is why everything goes through JSON.stringify and JSON.parse. The try block covers a corrupted value.
How the code works#
The whole app follows one loop: event → change the array → save → render. The page is always a picture of the array, never the source of truth. That is the same idea React and every other framework is built on, minus the framework.
Questions people ask#
Where does localStorage keep the data?
In the browser, tied to that site and that browser only. It does not sync between devices, and clearing site data removes it. For real syncing you need a server.
Is re-rendering the whole list slow?
Not at this size. Rebuilding a hundred rows is imperceptible. Frameworks optimise this for very large lists; you do not need to.
Why Date.now() for ids?
It is simple and unique enough here. crypto.randomUUID() is the better choice if items might be created in the same millisecond.
Where to go next#
- Build a quiz app
- Load data from an API instead
- JavaScript localStorage explained — how the saving in this project works