A To-Do List with localStorage: Persistent, Safe and Keyboard Friendly
Build a to-do list that remembers your tasks using localStorage. Covers safe rendering, event delegation, error handling and common storage bugs.

A to-do list is the classic first project for a reason: it touches almost every basic skill, from forms and events to rendering and saving data. It is also a good place to learn habits that matter in bigger apps, like never trusting text from a user and never assuming storage works.
This version is small, but it avoids the mistakes beginners usually ship.
What you will build
A list where you add tasks, tick them off, delete them, and clear the finished ones. Reload the page, or close the tab and come back, and everything is still there.
The frame is small, so open the demo in its own tab to try it at full size.
The data first
Before any HTML, decide what a task is. Here it is a plain object:
{ id: "a1b2", text: "Buy printer paper", done: false }
The whole app is an array of these objects. The page is a picture of that array, and localStorage is a copy of it. Whenever the array changes, you update the page and save it. Keeping that order of thought prevents most bugs.
The markup
<!-- In the <head>: <link rel="stylesheet" href="/style.css"> -->
<main class="page">
<h1>Today</h1>
<form class="add" id="add">
<label class="sr-only" for="text">New task</label>
<input id="text" type="text" placeholder="Add a task" autocomplete="off" required>
<button class="btn btn--primary" type="submit">Add</button>
</form>
<ul class="list" id="list"></ul>
<div class="footer">
<span id="summary"></span>
<button class="btn" id="clear" type="button" hidden>Clear completed</button>
</div>
</main>
<script src="/script.js" defer></script>
The list is empty in the HTML because JavaScript fills it from storage. The input has a real label, hidden visually with an sr-only class, so screen reader users know what the field is for.
The JavaScript
const KEY = "demo-todos";
const form = document.querySelector("#add");
const input = document.querySelector("#text");
const list = document.querySelector("#list");
const summary = document.querySelector("#summary");
const clearButton = document.querySelector("#clear");
function load() {
try {
const saved = JSON.parse(localStorage.getItem(KEY));
return Array.isArray(saved) ? saved : [];
} catch {
return [];
}
}
function save() {
try {
localStorage.setItem(KEY, JSON.stringify(todos));
} catch {
// Storage can be full or blocked. The list still works until the page closes.
}
}
let todos = load();
function createItem(todo) {
const item = document.createElement("li");
item.dataset.id = todo.id;
item.classList.toggle("is-done", todo.done);
const label = document.createElement("label");
const box = document.createElement("input");
box.type = "checkbox";
box.checked = todo.done;
// textContent, never innerHTML: the text came from a person.
const text = document.createElement("span");
text.textContent = todo.text;
label.append(box, text);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "remove";
remove.setAttribute("aria-label", `Delete ${todo.text}`);
remove.textContent = "\u00d7";
item.append(label, remove);
return item;
}
function updateSummary() {
const left = todos.filter((todo) => !todo.done).length;
summary.textContent = `${left} of ${todos.length} left`;
clearButton.hidden = left === todos.length;
}
function render() {
list.replaceChildren(...todos.map(createItem));
updateSummary();
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const text = input.value.trim();
if (!text) return;
const id = crypto.randomUUID ? crypto.randomUUID() : String(Date.now());
todos.push({ id, text, done: false });
input.value = "";
save();
render();
});
// Toggle in place, so keyboard focus stays on the checkbox.
list.addEventListener("change", (event) => {
const item = event.target.closest("li");
const todo = todos.find((entry) => entry.id === item.dataset.id);
todo.done = event.target.checked;
item.classList.toggle("is-done", todo.done);
save();
updateSummary();
});
list.addEventListener("click", (event) => {
if (!event.target.closest(".remove")) return;
const item = event.target.closest("li");
todos = todos.filter((entry) => entry.id !== item.dataset.id);
item.remove();
save();
updateSummary();
input.focus();
});
clearButton.addEventListener("click", () => {
todos = todos.filter((todo) => !todo.done);
save();
render();
});
render();
A few lines deserve a closer look.
load wraps JSON.parse in try and catch, and checks Array.isArray. If storage is empty, blocked, or holds something corrupted, the app starts with an empty list instead of crashing.
createItem sets the task text with textContent. The text came from a person, so it is never inserted as HTML.
The list uses event delegation: one change listener and one click listener on the ul handle every task, including ones added later, so you never attach listeners per item.
Common problems and how to fix them
Tasks vanish in private browsing or when storage is blocked
Some browsers and privacy settings throw an error when a script touches localStorage. Both load and save catch it, so the app keeps working for the session and simply cannot remember it afterward.
The page shows "[object Object]" after a reload
localStorage only stores strings. Saving an array directly turns it into text like [object Object]. Use JSON.stringify when saving and JSON.parse when loading, as the demo does.
A task containing HTML breaks the page
If you build items with innerHTML and a user types <img src=x onerror=...>, you have an injection bug. Creating elements and assigning textContent makes that impossible. This is the habit to keep, whatever you build next.
Focus jumps away after ticking a task
Rebuilding the whole list on every change throws away the checkbox the keyboard user was on. The change handler updates the one item in place and refreshes the counter, so focus stays where it was. After deleting a task, focus is returned to the input, since the item it was on no longer exists.
The list is different in two open tabs
Each tab loads its own copy at startup, so changes in one are not visible in the other until reload. If you want them in sync, listen for the storage event:
window.addEventListener("storage", (event) => {
if (event.key === KEY) {
todos = load();
render();
}
});
It is not saved on another device
localStorage lives in one browser on one device. For tasks that should follow a person around, you need an account and a server. For a personal list, it is a perfectly good home.