What you will build
A to-do list that:
- adds tasks from a form (Enter or the Add button),
- marks tasks done and not done,
- edits a task's text with a double-click or the Edit button,
- deletes tasks with a short fade,
- filters between All, Active and Done,
- shows how many tasks are left, and clears all done tasks at once,
- saves everything in the browser with
localStorage, so it survives a reload.
It uses almost every part of this course. Read the full code first, run it, then read the explanation below.
The complete app
<style>
#app { max-width: 420px; font-family: system-ui, sans-serif; }
#add-form { display: flex; gap: 6px; }
#add-form input { flex: 1; padding: 6px; }
#tasks { list-style: none; padding: 0; }
#tasks li { display: flex; align-items: center; gap: 6px; padding: 6px 0; border-bottom: 1px solid #e2e8f0; }
#tasks li .text { flex: 1; }
#tasks li.done .text { text-decoration: line-through; color: #94a3b8; }
#filters button[aria-pressed="true"] { font-weight: bold; text-decoration: underline; }
.error { color: crimson; min-height: 1.2em; margin: 4px 0; }
</style>
<div id="app">
<h2>My tasks</h2>
<form id="add-form">
<label for="new-task" class="visually-hidden">New task</label>
<input id="new-task" placeholder="What needs doing?" autocomplete="off">
<button>Add</button>
</form>
<p class="error" id="error" aria-live="polite"></p>
<ul id="tasks"></ul>
<footer>
<span id="count" aria-live="polite"></span>
<span id="filters">
<button type="button" data-filter="all" aria-pressed="true">All</button>
<button type="button" data-filter="active" aria-pressed="false">Active</button>
<button type="button" data-filter="done" aria-pressed="false">Done</button>
</span>
<button type="button" id="clear-done">Clear done</button>
</footer>
</div>
<script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
<script>
jQuery(function ($) {
"use strict";
const STORAGE_KEY = "jquery-course-tasks";
const $list = $("#tasks");
let filter = "all";
let tasks = load();
let nextId = tasks.reduce((max, task) => Math.max(max, task.id), 0) + 1;
// ---------- Saving (storage can be blocked, so never let it break the app) ----------
function load() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; } catch (error) { return []; }
}
function save() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks)); } catch (error) { /* keep working in memory */ }
}
// ---------- Drawing the list from the data ----------
function taskItem(task) {
return $("<li>", { "data-id": task.id, "class": task.done ? "done" : "" }).append(
$("<button>", { type: "button", "class": "toggle", text: task.done ? "Undo" : "Done", "aria-label": (task.done ? "Mark not done: " : "Mark done: ") + task.text }),
$("<span>", { "class": "text", text: task.text }),
$("<button>", { type: "button", "class": "edit", text: "Edit", "aria-label": "Edit: " + task.text }),
$("<button>", { type: "button", "class": "delete", text: "Delete", "aria-label": "Delete: " + task.text })
);
}
function render() {
const visible = tasks.filter(task => filter === "all" || (filter === "done") === task.done);
$list.empty().append(visible.map(taskItem));
const left = tasks.filter(task => !task.done).length;
$("#count").text(left + (left === 1 ? " task left" : " tasks left"));
$("#clear-done").prop("disabled", !tasks.some(task => task.done));
$("#filters button").each(function () {
$(this).attr("aria-pressed", $(this).data("filter") === filter);
});
}
// Every change goes through here: redraw, save, and tell anyone listening
function changed() {
render();
save();
$(document).trigger("tasks:changed", [tasks]);
}
const findTask = element => tasks.find(task => task.id === Number($(element).closest("li").data("id")));
// ---------- Adding ----------
$("#add-form").on("submit", function (event) {
event.preventDefault();
const $input = $("#new-task");
const text = $input.val().trim();
if (!text) {
$("#error").text("Please type a task first.");
$input.trigger("focus");
return;
}
$("#error").text("");
tasks.push({ id: nextId++, text, done: false });
$input.val("").trigger("focus");
changed();
});
// ---------- Actions on tasks (delegated: tasks are redrawn all the time) ----------
$list.on("click", ".toggle", function () {
const task = findTask(this);
task.done = !task.done;
changed();
});
$list.on("click", ".delete", function () {
const task = findTask(this);
$(this).closest("li").fadeOut(150, function () {
tasks = tasks.filter(t => t !== task);
changed();
});
});
$list.on("click", ".edit", function () { startEditing($(this).closest("li")); });
$list.on("dblclick", ".text", function () { startEditing($(this).closest("li")); });
function startEditing($item) {
const task = findTask($item);
const $field = $("<input>", { "class": "editing", value: task.text, "aria-label": "Edit task" });
$item.find(".text").replaceWith($field);
$field.trigger("focus");
$field.on("keydown", function (event) {
if (event.key === "Enter") finish(true);
if (event.key === "Escape") finish(false);
}).on("blur", () => finish(true));
let finished = false;
function finish(keep) {
if (finished) return;
finished = true;
const text = $field.val().trim();
if (keep && text) task.text = text;
changed();
}
}
// ---------- Filters and clearing ----------
$("#filters").on("click", "button", function () {
filter = $(this).data("filter");
render();
});
$("#clear-done").on("click", function () {
tasks = tasks.filter(task => !task.done);
changed();
});
// ---------- Independent listeners ----------
$(document).on("tasks:changed", function (event, list) {
console.log("tasks:", list.map(task => task.text + (task.done ? " (done)" : "")).join(", ") || "(none)", "|", $("#count").text());
});
render();
});
</script>tasks: Buy milk | 1 task left
tasks: Buy milk, Call Ravi | 2 tasks left
tasks: Buy milk, Call Ravi, Pay rent | 3 tasks left
tasks: Buy milk (done), Call Ravi, Pay rent | 2 tasks left
tasks: Buy milk (done), Call Ravi | 1 task left
tasks: Call Ravi | 1 task leftThe simulated session in the output added three tasks, marked "Buy milk" done, switched to the Active filter and back, deleted "Pay rent" and cleared the done task. Each line was printed by the tasks:changed listener. Try it yourself in the editor: add, edit with a double-click, filter and delete.
In the live editor, localStorage is blocked because the preview runs in a sandboxed frame, so the app silently keeps its tasks in memory. Save the code as an .html file and open it in your browser, and your tasks will survive a reload.
How it is built
Data first
The app keeps its state in a plain array:
[{ id: 1, text: "Buy milk", done: true }, { id: 2, text: "Call Ravi", done: false }]Every action changes the array, then calls changed(), which redraws the list from the array with render(). The page is never the source of truth; it is just a picture of the data. That is why filtering, counting and saving are simple: they all read the same array. Each task has an id, stored on its li as data-id, so a click can find the right task with findTask().
Building elements safely
taskItem() creates each li with $("<tag>", { text: ... }). Task text comes from a user, so it is always set as text, never glued into HTML. Try adding a task like <b>bold</b>: it appears exactly as typed. Each button also has an aria-label that names its task, so a screen reader announces "Delete: Call Ravi" instead of three identical "Delete" buttons.
One redraw, inserted once
render() empties the list and inserts all items in one .append() call with an array, the batching habit from the performance lesson.
Delegation
The Done, Edit and Delete buttons are created again on every redraw, so handlers attached directly to them would be lost. All three are delegated to #tasks, which never changes.
The form
Adding uses the form's submit event, so both the Add button and Enter work. Empty input shows a message in an aria-live region and returns focus to the field.
Editing
startEditing() swaps the text for an input with .replaceWith(). Enter or leaving the field saves; Escape cancels. The finished flag stops the save running twice when Enter is pressed (the redraw removes the field, which also fires blur).
Custom events
changed() triggers tasks:changed. The console logger listens for it without the rest of the code knowing it exists. You could add a "you finished everything!" message the same way, with one more listener and no other changes.
Storage
load() and save() wrap localStorage in try/catch. Storage can be blocked in private windows, full, or (as in this course's editor) unavailable in sandboxed frames. The app keeps working in memory instead of crashing.
Common mistakes
- Reading state back from the page, for example counting
li.doneelements, instead of from the array. With filters on, hidden tasks are not on the page, so the count would be wrong. - Attaching handlers to the task buttons directly. They disappear on the next redraw; delegate to
#tasks. - Building task HTML with string concatenation, which lets a task's text inject markup.
- Calling
render()but forgettingsave()(or the other way round). Routing every change through onechanged()function prevents that. - Letting
localStorageerrors stop the app.
Exercise
Extend the app with one or more of these features:
- Add a due date field and show overdue tasks in red.
- Add Move up and Move down buttons that reorder tasks (hint: change the array order, then call
changed()). - Show a short "Task deleted. Undo" message with
.fadeIn()and.delay(), and restore the task if Undo is clicked. - Load starting tasks from
https://jsonplaceholder.typicode.com/todos?userId=1&_limit=5with$.getJSON()when storage is empty, showing a loading state and handling errors. - Turn the app into a plugin:
$("#app").taskList({ storageKey: "work" }), so a page can have several independent lists.
Show one possible answer for Move up
Add a button in taskItem():
$("<button>", { type: "button", "class": "up", text: "Up", "aria-label": "Move up: " + task.text })Then one more delegated handler that swaps the task with the one before it in the array:
$list.on("click", ".up", function () {
const task = findTask(this);
const index = tasks.indexOf(task);
if (index > 0) {
[tasks[index - 1], tasks[index]] = [tasks[index], tasks[index - 1]];
changed();
}
});Because the list is always redrawn from the array, changing the order of the data is all it takes; the page, the saved copy and the counter follow automatically.
What you have learned
To build this app you used document ready, selectors and traversal, creating and inserting elements, text versus HTML, classes and attributes, .data(), form and keyboard events, delegation, effects with callbacks, custom events, and careful handling of storage that might fail. Those are the same skills you need to maintain, fix and extend jQuery on real websites. When you are ready, take the final exam to earn your certificate.
Key points
- Keep the data (an array of task objects) separate from the page, and redraw the list from the data after every change.
- Use one form submit handler to add tasks and delegated handlers for buttons on tasks that do not exist yet.
- Announce changes with a custom event so the counter, filters and saving each react on their own.
- Build elements with text, never HTML strings, and guard localStorage with try/catch.
Check yourself
Final exam and certificate
Finish all 45 lessons to unlock a 15-question exam on jQuery. Pass it and you get a certificate with a public link you can share.
Sign in to take the exam