Before this lesson
Understand arrays, stable IDs, DOM events, JSON and local storage. Use a separate practice file.
What you will be able to do
Implement add, complete, delete and reload recovery with a clear state-to-DOM relationship.
Understand the mechanism
Start with stable id, text and done fields, with the array as the source of truth. DOM is a rendering of that state. Delete by ID, not visible index, so sorting and filtering cannot target the wrong record.
Implement actions next: trim and reject empty additions, update done on completion, and remove the target on deletion. Save and render through a consistent path. Use createElement and textContent so task text never becomes markup.
Load the project storage key at startup and catch failures. The example filters records, while a production version should also cap text length and item count. Keep in-memory use available on save failure and explain that a refresh may lose changes.
Finally show remaining count, clear successful input and preserve focus. When deletion removes the focused control, move focus somewhere sensible. Accounts, cross-device sync and collaboration require capabilities beyond localStorage and are outside this project.
Runnable example
<!doctype html>
<html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>My tasks</title><style>
body{max-width:600px;margin:40px auto;padding:20px;font:18px/1.6 system-ui}
form{display:flex;gap:8px;flex-wrap:wrap}input,button{font:inherit;padding:8px;max-width:100%;box-sizing:border-box}
li{margin:14px 0}li button{margin-left:12px}input[type=checkbox]{margin-right:10px}
</style></head><body><h1>My tasks</h1>
<form id="form"><label for="task">New task</label>
<input id="task" required maxlength="120"><button>Add</button></form>
<p id="status" role="status"></p><p id="remaining"></p><ul id="list"></ul>
<script>
const key = "honeysuck-demo-tasks";
const list = document.querySelector("#list");
const status = document.querySelector("#status");
let tasks = [];
try {
const saved = JSON.parse(localStorage.getItem(key) || "[]");
if (Array.isArray(saved)) tasks = saved.filter(t => t && typeof t.text === "string" && t.text.trim() && t.text.length <= 120).slice(0, 200).map(t => ({ id: crypto.randomUUID(), text: t.text, done: t.done === true }));
} catch { status.textContent = "Starting without saved tasks."; }
function save() {
try { localStorage.setItem(key, JSON.stringify(tasks)); }
catch { status.textContent = "Storage unavailable. Tasks last until this page closes."; }
}
function render() {
document.querySelector("#remaining").textContent = `${tasks.filter(t => !t.done).length} remaining`;
list.replaceChildren();
tasks.forEach(task => {
const li = document.createElement("li");
const label = document.createElement("label");
const check = document.createElement("input");
check.type = "checkbox"; check.checked = Boolean(task.done);
const text = document.createElement("span"); text.textContent = task.text;
text.style.textDecoration = task.done ? "line-through" : "none";
check.addEventListener("change", () => { task.done = check.checked; save(); render(); const checks = list.querySelectorAll("input"); const position = tasks.findIndex(t => t.id === task.id); checks[position]?.focus(); });
const remove = document.createElement("button");
remove.type = "button"; remove.textContent = "Delete";
remove.setAttribute("aria-label", "Delete " + task.text);
remove.addEventListener("click", () => { tasks = tasks.filter(t => t.id !== task.id); save(); render(); document.querySelector("#task").focus(); });
label.append(check, text); li.append(label, remove); list.append(li);
});
}
document.querySelector("#form").addEventListener("submit", event => {
event.preventDefault();
const input = document.querySelector("#task"); const text = input.value.trim();
if (!text || text.length > 120 || tasks.length >= 200) { status.textContent = "Enter 1–120 characters; maximum 200 tasks."; return; }
tasks.push({id:crypto.randomUUID(), text, done:false}); input.value = ""; status.textContent = "Task added.";
save(); render(); input.focus();
});
render();
</script></body></html>
Trace and verify
Accept in order: whitespace adds nothing; duplicate titles remain independently editable; refresh preserves state; markup-like text remains literal; corrupted project storage does not blank the page. Then check keyboard behavior and 320 pixel layout.
Common mistakes and boundaries
Do not clear all same-origin storage. Simple demo IDs suit a local exercise; multi-tab or server synchronization needs conflict handling. Re-rendering can lose focus, so include focus restoration in refinement. crypto.randomUUID requires a secure context such as HTTPS or localhost. Loading assigns fresh page-unique IDs; cross-tab synchronization is outside this example.
Practice challenge
Add All, Active and Completed filters while retaining deletion by stable ID. Filter state may remain in memory.
Reference solution and reasoning
Keep the full tasks array and derive a visible list with filter before rendering. Never replace source data with the filtered result. Count remaining items from all tasks and distinguish an empty collection from an empty filter.
Project lab: failed persistence needs an explicit outcome
Adding an item and persisting it are separate outcomes. Browser storage can be unavailable or full. Inject a storage adapter so a failing test double can reproduce that condition deterministically. This lab does not write to real localStorage.
Run this separate experiment in a modern browser Console; it is not a replacement for the main example above.
function saveItems(storage, items) {
try {
storage.setItem('todo-v1', JSON.stringify({version: 1, items}));
return {saved: true, message: 'Saved'};
} catch {
return {saved: false, message: 'Not saved: keep this tab open and export your data'};
}
}
const memory = new Map();
const working = {setItem: (key, value) => memory.set(key, value)};
const failing = {setItem: () => { throw new Error('Storage unavailable'); }};
const items = [{id: 'a', title: 'Learn HTML', done: false}];
console.log(saveItems(working, items).saved);
console.log(saveItems(failing, items).saved);
console.log(JSON.parse(memory.get('todo-v1')).items.length);
console.assert(items.length === 1);
Expected observations
Output is true, false and 1. The failure does not delete the in-memory item or replace the earlier stored representation.
Why this happens and where it stops applying
Connect the returned outcome to a visible save-status message. A console log does not tell ordinary users whether closing the tab is safe. A real application can offer JSON export before exit; imported data still needs schema, ID and size validation and safe text rendering. Deleting the final item is a valid empty list, whereas failure to read stored data should preserve a recovery path.
Extension challenge
Define manual acceptance cases for normal saves, failed writes, reload recovery and malformed JSON.
Reasoned solution
After a successful save, reload must preserve IDs. A failed write keeps the visible item and marks it unsaved. Malformed JSON is not overwritten automatically, while a valid empty list restores the empty state. Also test literal HTML-looking input and independent deletion of equal-titled items by ID.
Further reading
Complete source and acceptance
Download the complete project source (.html.txt)
Save the downloaded text as a .html file in a separate practice directory and open it in your browser. It contains the main project above; the later labs are separate exercises. Data stays in that browser origin, not a server account.