Project: todo app with localStorage
Projects and patterns18 min readLesson 2 of 4
2.1 The project
This project extends the task list from module 4 so the tasks survive a page refresh. The page keeps an array of task objects, loads it from localStorage at start-up, renders it, and saves after every change. The rest of the interface stays the same: a form for new tasks, a list with toggles and deletes, and a message area for validation.
The storage layer is the new part. A small module of two functions, load and save, keeps the JSON handling in one place. The rest of the code sees a plain array and never reads a raw string from storage.
The project is deliberately small enough to finish in one sitting and complete enough to show real patterns: a versioned key, a single source of truth, a render function, delegated events and a validation path.
2.2 Files and start-up
The folder contains index.html, styles.css and app.js, plus optional storage.js if the storage helpers are split out. The HTML holds the heading, form, message area and an empty list. The script runs after the document with defer or as a module.
Start-up order matters. Load the tasks first, render them, then attach listeners. Attaching first is harmless, but rendering an empty list before loading the stored data causes a visible flash and can lose the selected task. One init() function that runs the steps in order keeps the sequence explicit.
If storage is unavailable, for example in a privacy mode, the app should still work for the session with an in-memory array. Catch the storage error, fall back to memory, and show a note rather than crashing at load.
2.3 The storage schema
Each task has an integer id, a non-empty title and a boolean done flag. The array is stored under a versioned key such as "tasks-v1". The version lets a later release detect old data and migrate or ignore it.
Keep the saved shape free of DOM nodes and functions. Only JSON-friendly data survives the round trip. If a future feature needs a due date, store it as an ISO string and convert it back when rendering, rather than storing a Date object.
A migrate function reads the old key and returns a valid array. It should handle missing data, corrupt JSON and unexpected shapes with a clear fallback. The cost of those few guards is small, and the alternative is a blank app after an upgrade.
const STORAGE_KEY = "tasks-v1";
localStorage.setItem(STORAGE_KEY, JSON.stringify([{ id: 1, title: "Learn storage", done: false }]));
function loadTasks() {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
}
function saveTasks(tasks) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}
const tasks = loadTasks();
tasks.push({ id: 2, title: "Build todo app", done: true });
saveTasks(tasks);
const reloaded = loadTasks();
console.log(reloaded.length);
console.log(JSON.stringify(reloaded.map((task) => task.done)));Output
2 [false,true]
2.4 Adding tasks
The submit handler trims the input, rejects an empty value and creates a task with a fresh id. Date.now() is a quick source of ids for a single-user app, though two tasks created in the same millisecond can collide. A counter based on the maximum existing id is more predictable for a task list.
After the array changes, save and render. Clearing the input and returning focus makes repeated entry pleasant. The message area shows a short error when validation fails and clears on the next successful add.
Validation belongs to a function such as validateTitle(value) that returns an error message or null. The submit handler stays short, and the rule can be tested without the DOM.
2.5 Rendering and delegation
The render function clears the list, rebuilds one item per task, and shows the empty-state message when the array is empty. Each item stores its id in data-id, displays the title, and carries a done class when the flag is true.
One click listener on the list handles toggles. A second control for delete can use a button inside the item, and the delegated handler reads event.target.closest("button"). Keeping delete and toggle in one listener means new items need no setup.
Saving after every change keeps storage and memory aligned. A save on toggle, add and delete is cheap for a task list. For a large document, save on a debounce instead, but avoid a render that changes data without saving it.
const list = document.createElement("ul");
document.body.append(list);
const tasks = [
{ id: 1, title: "Learn storage", done: false },
{ id: 2, title: "Build todo app", done: true },
];
function render() {
list.innerHTML = "";
for (const task of tasks) {
const item = document.createElement("li");
item.dataset.id = String(task.id);
item.textContent = task.title;
if (task.done) item.classList.add("done");
list.append(item);
}
}
list.addEventListener("click", (event) => {
const item = event.target.closest("li");
if (!item) return;
const task = tasks.find((entry) => String(entry.id) === item.dataset.id);
tasks.splice(tasks.indexOf(task), 1);
render();
});
render();
list.querySelector("li").click();
console.log(list.children.length);
console.log(JSON.stringify([...list.children].map((item) => item.textContent)));Output
1 ["Build todo app"]
2.6 Testing the app by hand
A short manual matrix covers most of the risk. Add a task, refresh the page, toggle it, delete it, refresh again, submit an empty title, and open the page with storage cleared. Each case has an expected visible result, and a failure points at one layer.
Use the browser developer tools to inspect storage. The Application tab shows the key and its JSON string, and you can edit or delete it to test corrupt data. The console shows errors with the line that threw, and the network tab is irrelevant here but will matter in the next project.
Test after a schema change too. Keep an old stored value, deploy the new code, and check that the migrate path produces a usable array rather than undefined fields. That scenario is easy to forget and hard to debug later.
2.7 Handling storage errors
setItem throws when the quota is full, when the browser blocks storage, or when the value is too large. Wrap the write in try catch and decide what the app does: keep working in memory with a warning, or ask the user to remove some data.
getItem can return null, and the parsed value can be undefined or a different shape from a previous version. A load function that returns an empty array on any failure keeps the app usable, but it also hides corruption, so log the error while falling back.
Private browsing and embedded iframes sometimes disable storage entirely. Feature-detect once at start-up, keep an in-memory fallback with the same interface, and show a small notice. The rest of the code should not care which storage implementation is active.
2.8 Saving less often
Writing to localStorage on every keystroke in a long note is wasted work, and the synchronous write can make typing feel slow. Save on a debounce, after the user pauses, or on a save button. The in-memory value stays current; storage catches up a moment later.
The same discipline applies to rendering. A search box can filter on every keystroke for a small list, but a larger list benefits from a debounce and an abort of the previous request. Measure the interaction in the performance panel before adding complexity, and keep the simple version when the list is short.
2.9 Common mistakes
- Rendering before loading storage, so the saved list flashes away.
- Forgetting to save after a toggle or delete.
- Storing the array without JSON.stringify and reading "[object Object]".
- Using an id scheme that collides and toggling the wrong task.
- Leaving the old key after a schema change and mixing shapes.
- Letting a storage quota error stop the whole app.
Exam
Project questions ask for a function or an interaction result. Follow the state: handler changes the array, save writes it, render rebuilds the list. If a refresh loses data, the save step or the load step is missing.
- Write loadTasks and saveTasks with JSON handling.
- Render the list from the array and show the empty state.
- Add a delegated listener for toggle and delete.
- Run the manual test matrix, including a refresh and corrupted storage.
