🧠 Why this lesson matters
So far (Step 9.4) we made our list interactive: clicking an item toggles a “completed” look. But if you refresh the page or close the tab, the list resets.
In this step we introduce persistence with the browser’s localStorage, so your list survives reloads without any backend.
📖 Key concepts (plain English)
- DOM (Document Object Model)
The DOM is the browser’s in-memory representation of your HTML. Every<ul>,<li>, etc. becomes an object that JavaScript can modify (add/remove classes, change text, delete nodes). Think of it as the “bridge” between your HTML and your JS. localStorage
A tiny key–value store built into the browser for each site. It stores strings and keeps them persistently (they remain after reloads).
Since we want to save an array of tasks, we’ll convert it to a string withJSON.stringify()and back withJSON.parse().
🎯 Goal
- Load the saved list from
localStoragewhen the page opens. - Save the list every time the user adds, completes, or deletes a task.
- Keep the interface accessible with ARIA roles and keyboard support (Space/Enter).
🧩 Starter HTML (compatible with Step 9.4)
<section class="todo">
<h3>My Tasks</h3>
<form id="add-form" autocomplete="off">
<input id="new-item" type="text" placeholder="Add a task…" />
<button type="submit">Add</button>
<button type="button" id="clear-done">Clear completed</button>
</form>
<ul id="todo-list">
<li class="todo-item" role="checkbox" aria-checked="false" tabindex="0">Buy coffee beans</li>
<li class="todo-item" role="checkbox" aria-checked="false" tabindex="0">Email the client</li>
<li class="todo-item" role="checkbox" aria-checked="false" tabindex="0">Book dentist appointment</li>
</ul>
<p id="counter" aria-live="polite"></p>
</section>
🎨 CSS (same as 9.4 + minor tweaks)
.todo-item {
cursor: pointer;
padding: 8px 10px;
border-bottom: 1px solid #eee;
transition: background .15s;
}
.todo-item:hover,
.todo-item:focus { background: #f7faff; outline: none; }
.todo-item.done { text-decoration: line-through; color: #6b7280; opacity: .85; }
#add-form { display: flex; gap: 8px; margin: 10px 0; }
#add-form input { flex: 1; padding: 8px 10px; border: 1px solid #ddd; border-radius: 8px; }
#add-form button { padding: 8px 12px; border: 0; border-radius: 8px; cursor: pointer; }
#add-form button[type="submit"] { background: #1a73e8; color: #fff; }
#clear-done { background: #eee; color: #222; }
.remove-btn { margin-left: 8px; background: #ffe3e3; border: 0; border-radius: 8px; cursor: pointer; }
🗂️ Data model
Each task is an object:
// { id: string, text: string, done: boolean }
We’ll keep an array of these objects as our single source of truth and render the DOM from it.
⚙️ JavaScript — full persistence
Paste this after your Step 9.4 script or replace it entirely. It keeps event delegation and ARIA updates, and adds save/load.
// ----- SELECTORS -----
const list = document.getElementById('todo-list');
const form = document.getElementById('add-form');
const input = document.getElementById('new-item');
const clearDoneBtn = document.getElementById('clear-done');
const counterEl = document.getElementById('counter');
// ----- STORAGE -----
const STORAGE_KEY = 'todo-list:v1';
function loadFromStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (e) {
console.warn('localStorage not available or corrupt data', e);
return null;
}
}
function saveToStorage(data) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
console.warn('Failed to save to localStorage', e);
}
}
// ----- STATE -----
let todos = []; // [{id, text, done}]
// Initialize from storage or from existing <li> elements (Step 9.4)
function bootstrapFromDOM() {
return [...list.querySelectorAll('.todo-item')].map(li => ({
id: crypto.randomUUID(),
text: li.textContent.trim(),
done: li.classList.contains('done')
}));
}
{
const saved = loadFromStorage();
todos = saved ?? bootstrapFromDOM();
saveToStorage(todos); // ensure a persisted initial state
}
// ----- RENDER -----
function updateCounter() {
const total = todos.length;
const done = todos.filter(t => t.done).length;
counterEl.textContent = `${done} completed out of ${total}`;
}
function render() {
list.innerHTML = '';
for (const t of todos) {
const li = document.createElement('li');
li.className = 'todo-item';
if (t.done) li.classList.add('done');
li.setAttribute('role', 'checkbox');
li.setAttribute('aria-checked', String(t.done));
li.tabIndex = 0;
li.dataset.id = t.id;
const name = document.createElement('span');
name.textContent = t.text;
const del = document.createElement('button');
del.className = 'remove-btn';
del.setAttribute('aria-label', 'Delete item');
del.textContent = '🗑';
li.append(name, del);
list.appendChild(li);
}
updateCounter();
}
// ----- ACTIONS -----
function addTodo(text) {
todos.push({ id: crypto.randomUUID(), text, done: false });
saveToStorage(todos);
render();
}
function toggleTodo(id) {
const t = todos.find(x => x.id === id);
if (!t) return;
t.done = !t.done;
saveToStorage(todos);
render();
}
function removeTodo(id) {
todos = todos.filter(x => x.id !== id);
saveToStorage(todos);
render();
}
function clearDone() {
if (!todos.some(t => t.done)) return;
if (!confirm('Delete all completed items?')) return;
todos = todos.filter(t => !t.done);
saveToStorage(todos);
render();
}
// ----- EVENTS (delegation + keyboard) -----
list.addEventListener('click', (e) => {
const delBtn = e.target.closest('.remove-btn');
if (delBtn) {
const id = delBtn.closest('.todo-item')?.dataset.id;
if (id) removeTodo(id);
return;
}
const item = e.target.closest('.todo-item');
if (!item || !list.contains(item)) return;
toggleTodo(item.dataset.id);
});
list.addEventListener('keydown', (e) => {
const item = e.target.closest('.todo-item');
if (!item) return;
const k = e.key.toLowerCase();
if (k === ' ' || k === 'enter') {
e.preventDefault();
toggleTodo(item.dataset.id);
} else if (k === 'delete' || k === 'backspace') {
e.preventDefault();
removeTodo(item.dataset.id);
}
});
form.addEventListener('submit', (e) => {
e.preventDefault();
const value = input.value.trim();
if (!value) return;
addTodo(value);
input.value = '';
input.focus();
});
clearDoneBtn.addEventListener('click', clearDone);
// Initial render
render();
💾 How localStorage works (quick recap)
- Per-site key–value store, string-only; we use
JSON.stringify()/JSON.parse()for arrays/objects. - Persistent: data survives reloads and browser restarts (until cleared).
- Synchronous: keep payloads small and focused (a to-do list is perfect).
🚫 Common mistakes
- Forgetting to call
saveToStorage()after changes → nothing persists. - Overwriting
classNameinstead of usingclassList.toggle()→ you might remove other classes by accident. - Not updating
aria-checkedwhen toggling.done→ visually OK but not accessible. - Attaching listeners to each
<li>instead of using event delegation on the<ul>.
🧪 Exercises
- Export/Import JSON: add buttons to download the list as
.jsonand re-upload it later. - Filter: “All / Active / Completed” using CSS classes (don’t remove from the data model).
- Inline edit: double-click a task to edit its text; save on Enter/blur.
📌 Key takeaways
- The DOM is the live structure your JS manipulates.
localStoragegives you persistence without a backend.- Keep a single source of truth (the
todosarray), then render the DOM from it. - Accessibility + persistence = a simple, real-world-ready app.
