This project brings the whole level together. All data lives in one state object. Every change goes through a small function that updates the state, saves it, and calls render() — the page is always drawn from the data, never edited piecemeal.
Events are delegated to the list, so new items need no extra listeners. Filters (All / Active / Done) are just a value in state that render() respects.
Text typed by users is inserted with textContent, never innerHTML, so a task like <img src=x onerror=alert(1)> is shown as plain text instead of running as code.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Tasks</title>
<style>
.done span { text-decoration: line-through; opacity: 0.6; }
.filters button[aria-pressed="true"] { font-weight: bold; }
</style>
<script src="app.js" defer></script>
</head>
<body>
<h1>Tasks</h1>
<form id="new-task">
<input name="title" placeholder="What needs doing?" required maxlength="120" />
<button>Add</button>
</form>
<div class="filters">
<button data-filter="all">All</button>
<button data-filter="active">Active</button>
<button data-filter="done">Done</button>
</div>
<ul id="tasks"></ul>
<p id="remaining"></p>
</body>
</html>const STORAGE_KEY = "dolese.tasks.v1";
const state = {
tasks: load(),
filter: "all",
};
function load() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
} catch {
return [];
}
}
function update(change) {
change(state);
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.tasks));
render();
}
const list = document.querySelector("#tasks");
const form = document.querySelector("#new-task");
function render() {
const visible = state.tasks.filter((t) =>
state.filter === "all" ? true : state.filter === "done" ? t.done : !t.done,
);
list.replaceChildren(
...visible.map((task) => {
const li = document.createElement("li");
li.dataset.id = task.id;
li.classList.toggle("done", task.done);
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = task.done;
checkbox.className = "toggle";
const title = document.createElement("span");
title.textContent = task.title; // safe: never innerHTML for user text
const remove = document.createElement("button");
remove.textContent = "Delete";
remove.className = "delete";
li.append(checkbox, " ", title, " ", remove);
return li;
}),
);
const left = state.tasks.filter((t) => !t.done).length;
document.querySelector("#remaining").textContent = `${left} task${left === 1 ? "" : "s"} left`;
for (const btn of document.querySelectorAll("[data-filter]")) {
btn.setAttribute("aria-pressed", String(btn.dataset.filter === state.filter));
}
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const title = form.elements.title.value.trim();
if (!title) return;
update((s) => s.tasks.push({ id: crypto.randomUUID(), title, done: false }));
form.reset();
});
list.addEventListener("click", (event) => {
const id = event.target.closest("li")?.dataset.id;
if (!id) return;
if (event.target.matches(".toggle")) {
update((s) => {
const task = s.tasks.find((t) => t.id === id);
task.done = !task.done;
});
} else if (event.target.matches(".delete")) {
update((s) => { s.tasks = s.tasks.filter((t) => t.id !== id); });
}
});
document.querySelector(".filters").addEventListener("click", (event) => {
const filter = event.target.dataset.filter;
if (filter) update((s) => { s.filter = filter; });
});
render();Key points
- Single source of truth: state → render(); never patch the page by hand.
- Route every change through one
update()that saves and re-renders. - Insert user text with
textContentto prevent script injection (XSS).
Exercise
Add: double-click a task to edit its title, a "Clear completed" button, a due date per task with overdue tasks highlighted, and keyboard support (Enter to save an edit, Escape to cancel).
Show solution
Try the exercise yourself first — then compare your approach with this one.
Every feature follows the same pattern: change state in update(), then render(). Editing stores the id being edited in state; the row then renders an input instead of the title. Enter saves, Escape cancels, and the input saves on blur too. Overdue tasks get a class when their due date is before today and they aren't done.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Tasks</title>
<style>
.done .title { text-decoration: line-through; opacity: 0.6; }
.overdue .due { color: #b00020; font-weight: bold; }
</style>
<script src="app.js" defer></script>
</head>
<body>
<form id="new-task">
<input name="title" placeholder="What needs doing?" required maxlength="120" />
<input name="due" type="date" />
<button>Add</button>
</form>
<ul id="tasks"></ul>
<button id="clear-done">Clear completed</button>
<p id="remaining"></p>
</body>
</html>const KEY = "dolese.tasks.v2";
const state = { tasks: load(), editing: null };
function load() {
try {
return JSON.parse(localStorage.getItem(KEY)) ?? [];
} catch {
return [];
}
}
function update(change) {
change(state);
localStorage.setItem(KEY, JSON.stringify(state.tasks));
render();
}
const list = document.querySelector("#tasks");
const form = document.querySelector("#new-task");
const today = new Date().toISOString().slice(0, 10);
function render() {
list.replaceChildren(
...state.tasks.map((task) => {
const li = document.createElement("li");
li.dataset.id = task.id;
li.classList.toggle("done", task.done);
li.classList.toggle("overdue", Boolean(task.due) && task.due < today && !task.done);
const toggle = document.createElement("input");
toggle.type = "checkbox";
toggle.checked = task.done;
toggle.className = "toggle";
let title;
if (state.editing === task.id) {
title = document.createElement("input");
title.className = "edit";
title.value = task.title;
} else {
title = document.createElement("span");
title.className = "title";
title.textContent = task.title;
}
const due = document.createElement("span");
due.className = "due";
due.textContent = task.due ? ` (due ${task.due})` : "";
const remove = document.createElement("button");
remove.textContent = "Delete";
remove.className = "delete";
li.append(toggle, " ", title, due, " ", remove);
return li;
}),
);
list.querySelector(".edit")?.focus();
const left = state.tasks.filter((t) => !t.done).length;
document.querySelector("#remaining").textContent = `${left} task${left === 1 ? "" : "s"} left`;
}
function finishEdit(input, save) {
const id = input.closest("li").dataset.id;
update((s) => {
const title = input.value.trim();
if (save && title) s.tasks.find((t) => t.id === id).title = title;
s.editing = null;
});
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const title = form.elements.title.value.trim();
if (!title) return;
update((s) => s.tasks.push({ id: crypto.randomUUID(), title, due: form.elements.due.value, done: false }));
form.reset();
});
list.addEventListener("click", (event) => {
const id = event.target.closest("li")?.dataset.id;
if (event.target.matches(".toggle")) update((s) => { const t = s.tasks.find((x) => x.id === id); t.done = !t.done; });
if (event.target.matches(".delete")) update((s) => { s.tasks = s.tasks.filter((t) => t.id !== id); });
});
list.addEventListener("dblclick", (event) => {
if (event.target.matches(".title")) update((s) => { s.editing = event.target.closest("li").dataset.id; });
});
list.addEventListener("keydown", (event) => {
if (!event.target.matches(".edit")) return;
if (event.key === "Enter") finishEdit(event.target, true);
if (event.key === "Escape") finishEdit(event.target, false);
});
list.addEventListener("focusout", (event) => {
if (event.target.matches(".edit") && state.editing) finishEdit(event.target, true);
});
document.querySelector("#clear-done").addEventListener("click", () => {
update((s) => { s.tasks = s.tasks.filter((t) => !t.done); });
});
render();