In short
In this project you will build a JavaScript to-do list app step by step. It adds, completes, deletes and filters tasks, counts what is left, and saves everything with localStorage.
In this project you will build a JavaScript to-do list app step by step. It adds, completes, deletes and filters tasks, counts what is left, and saves everything with localStorage.
What you will build
This project brings together everything from this part of the course: selecting elements, creating elements, events, forms, event delegation, classes and localStorage. Your finished app will:
- Add a task from a form.
- Mark a task as done with a checkbox (the text gets crossed out).
- Delete a task with a button, using event delegation.
- Filter tasks: All, Active and Done.
- Show a counter like "2 tasks left".
- Save tasks in localStorage, so they survive a reload.
Create a folder with three files: index.html, style.css and app.js. We will build app.js piece by piece, and the complete code is at the end.
Step 1: The HTML structure
We need a form with one text field, a row of filter buttons, an empty list and a counter. JavaScript will fill the list.
<form id="task-form">
<label for="task-input">New task</label>
<input id="task-input" name="task" type="text" required maxlength="100" placeholder="What needs doing?" autocomplete="off">
<button type="submit">Add</button>
</form>
<div class="filters" aria-label="Filter tasks">
<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>
</div>
<ul id="task-list"></ul>
<p id="counter" aria-live="polite"></p>
The filter buttons use data-filter so JavaScript knows which filter each one means. aria-pressed tells screen reader users which filter is on. aria-live on the counter means the new count is announced when it changes.
Step 2: Store tasks as data and render them
The key idea of this app: the array of tasks is the truth, and the list on screen is just a picture of it. We never read tasks from the DOM. When something changes, we update the array, then call one render() function that redraws the list.
Each task is an object like { id: 1767000000000, text: "Buy milk", done: false }. The id lets us find the right task later. We use Date.now(), which is different every millisecond.
let tasks = [];
let currentFilter = "all";
const list = document.querySelector("#task-list");
const render = () => {
list.textContent = "";
const fragment = document.createDocumentFragment();
for (const task of tasks) {
const li = document.createElement("li");
li.className = "task";
li.dataset.id = task.id;
li.classList.toggle("done", task.done);
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "toggle";
checkbox.checked = task.done;
checkbox.setAttribute("aria-label", `Mark "${task.text}" as done`);
const text = document.createElement("span");
text.textContent = task.text;
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "delete";
deleteButton.textContent = "Delete";
deleteButton.setAttribute("aria-label", `Delete "${task.text}"`);
li.append(checkbox, text, deleteButton);
fragment.append(li);
}
list.append(fragment);
};
list.textContent = "" empties the list before drawing it again. The task text goes in with textContent, so anything the user types is shown safely as text. classList.toggle("done", task.done) adds the done class only for completed tasks.
Step 3: Add tasks with the form
Listen for submit, stop the reload, read the text, and push a new task into the array.
const form = document.querySelector("#task-form");
const input = document.querySelector("#task-input");
form.addEventListener("submit", (event) => {
event.preventDefault();
const text = input.value.trim();
if (text === "") return;
tasks.push({ id: Date.now(), text, done: false });
render();
form.reset();
input.focus();
});
trim() removes extra spaces, and we ignore empty tasks. After adding, form.reset() clears the field and focus() puts the cursor back so the user can type the next task right away.
Step 4: Mark done and delete with event delegation
The list items are created and destroyed all the time, so we use one listener on the <ul>. We find the clicked task's <li> with closest(), read its data-id, and decide what to do based on what was clicked.
list.addEventListener("click", (event) => {
const li = event.target.closest("li");
if (!li) return;
const id = Number(li.dataset.id);
if (event.target.closest(".delete")) {
tasks = tasks.filter((task) => task.id !== id);
} else if (event.target.closest(".toggle")) {
const task = tasks.find((task) => task.id === id);
task.done = !task.done;
} else {
return;
}
render();
});
dataset.id is a string, so we convert it with Number() before comparing. Deleting uses filter to keep every task except the clicked one. Toggling flips done between true and false. Clicks anywhere else in the item do nothing.
Tip: This "change the data, then render" pattern is how most modern front-end frameworks work. Learning it with plain JavaScript makes those tools much easier later.
Step 5: Filter tasks and count what is left
Now update render() so it shows only the tasks that match currentFilter, updates the counter and highlights the active filter button. Then listen for clicks on the filter buttons, again with delegation.
const counter = document.querySelector("#counter");
const filters = document.querySelector(".filters");
const getVisibleTasks = () => {
if (currentFilter === "active") return tasks.filter((task) => !task.done);
if (currentFilter === "done") return tasks.filter((task) => task.done);
return tasks;
};
// Inside render(): loop over getVisibleTasks() instead of tasks, then add:
const left = tasks.filter((task) => !task.done).length;
counter.textContent = `${left} ${left === 1 ? "task" : "tasks"} left`;
for (const button of filters.querySelectorAll("button")) {
const isActive = button.dataset.filter === currentFilter;
button.classList.toggle("active", isActive);
button.setAttribute("aria-pressed", String(isActive));
}
filters.addEventListener("click", (event) => {
const button = event.target.closest("button[data-filter]");
if (!button) return;
currentFilter = button.dataset.filter;
render();
});
The counter always counts all unfinished tasks, not just the visible ones. The ternary picks "task" or "tasks" so the grammar is right.
Step 6: Save and load with localStorage
Finally, save the array every time it changes and load it when the page opens. We wrap loading in try...catch so broken data never crashes the app.
const STORAGE_KEY = "codecraft-todos";
const saveTasks = () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
};
const loadTasks = () => {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
return Array.isArray(saved) ? saved : [];
} catch (error) {
console.error("Could not load tasks:", error);
return [];
}
};
Call saveTasks() right before each render() in the submit and list listeners, and start the app with tasks = loadTasks(). Array.isArray makes sure we really got a list.
Common mistake: Saving only when the page closes. Closing events are not reliable, especially on phones. Save straight after every change instead.
The complete to-do list code
Here are all three files. Open index.html in your browser, add some tasks, tick a few, try the filters and reload the page.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>To-Do List</title>
<link rel="stylesheet" href="style.css">
<script src="app.js" defer></script>
</head>
<body>
<main class="app">
<h1>My tasks</h1>
<form id="task-form">
<label for="task-input">New task</label>
<input id="task-input" name="task" type="text" required maxlength="100" placeholder="What needs doing?" autocomplete="off">
<button type="submit">Add</button>
</form>
<div class="filters" aria-label="Filter tasks">
<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>
</div>
<ul id="task-list"></ul>
<p id="counter" aria-live="polite"></p>
</main>
</body>
</html>
body { font-family: system-ui, sans-serif; background: #f4f5f7; color: #1f2933; margin: 0; padding: 24px 16px; }
.app { max-width: 480px; margin: 0 auto; background: #fff; padding: 24px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); }
#task-form { display: flex; flex-wrap: wrap; gap: 8px; }
#task-form label { width: 100%; font-weight: 600; }
#task-input { flex: 1; padding: 10px; border: 1px solid #c4c9d0; border-radius: 6px; font-size: 1rem; }
button { padding: 8px 14px; border: 1px solid #3b5bdb; border-radius: 6px; background: #fff; color: #3b5bdb; cursor: pointer; }
#task-form button, .filters button.active { background: #3b5bdb; color: #fff; }
.filters { display: flex; gap: 8px; margin: 16px 0; }
#task-list { list-style: none; padding: 0; margin: 0; }
.task { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #e4e7eb; }
.task span { flex: 1; }
.task.done span { text-decoration: line-through; color: #8a94a0; }
.task .delete { border-color: #c92a2a; color: #c92a2a; }
#counter { color: #52606d; margin-bottom: 0; }
const STORAGE_KEY = "codecraft-todos";
const form = document.querySelector("#task-form");
const input = document.querySelector("#task-input");
const list = document.querySelector("#task-list");
const filters = document.querySelector(".filters");
const counter = document.querySelector("#counter");
const loadTasks = () => {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
return Array.isArray(saved) ? saved : [];
} catch (error) {
console.error("Could not load tasks:", error);
return [];
}
};
const saveTasks = () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
};
let tasks = loadTasks();
let currentFilter = "all";
const getVisibleTasks = () => {
if (currentFilter === "active") return tasks.filter((task) => !task.done);
if (currentFilter === "done") return tasks.filter((task) => task.done);
return tasks;
};
const createTaskElement = (task) => {
const li = document.createElement("li");
li.className = "task";
li.dataset.id = task.id;
li.classList.toggle("done", task.done);
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "toggle";
checkbox.checked = task.done;
checkbox.setAttribute("aria-label", `Mark "${task.text}" as done`);
const text = document.createElement("span");
text.textContent = task.text;
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "delete";
deleteButton.textContent = "Delete";
deleteButton.setAttribute("aria-label", `Delete "${task.text}"`);
li.append(checkbox, text, deleteButton);
return li;
};
const render = () => {
list.textContent = "";
const fragment = document.createDocumentFragment();
for (const task of getVisibleTasks()) {
fragment.append(createTaskElement(task));
}
list.append(fragment);
const left = tasks.filter((task) => !task.done).length;
counter.textContent = `${left} ${left === 1 ? "task" : "tasks"} left`;
for (const button of filters.querySelectorAll("button")) {
const isActive = button.dataset.filter === currentFilter;
button.classList.toggle("active", isActive);
button.setAttribute("aria-pressed", String(isActive));
}
};
form.addEventListener("submit", (event) => {
event.preventDefault();
const text = input.value.trim();
if (text === "") return;
tasks.push({ id: Date.now(), text, done: false });
saveTasks();
render();
form.reset();
input.focus();
});
list.addEventListener("click", (event) => {
const li = event.target.closest("li");
if (!li) return;
const id = Number(li.dataset.id);
if (event.target.closest(".delete")) {
tasks = tasks.filter((task) => task.id !== id);
} else if (event.target.closest(".toggle")) {
const task = tasks.find((task) => task.id === id);
task.done = !task.done;
} else {
return;
}
saveTasks();
render();
});
filters.addEventListener("click", (event) => {
const button = event.target.closest("button[data-filter]");
if (!button) return;
currentFilter = button.dataset.filter;
render();
});
render();
console.log(`Loaded ${tasks.length} tasks`);
Loaded 0 tasks
On the first visit the console says Loaded 0 tasks and the counter shows "0 tasks left". Add "Buy milk" and "Call mum", tick "Buy milk", and you will see it crossed out with the counter at "1 task left". Click "Active" to hide it, "Done" to see only it, and "All" to see both. Reload the page: everything is still there.
Notice how saveTasks uses tasks before the let tasks line. That is fine, because the function only runs later, after tasks exists.
Practice exercise
Extend your app with two new features. First, add a "Clear done" button that removes all completed tasks at once. Second, show a friendly message such as "Nothing here yet. Add your first task!" when the visible list is empty.
Hint: For "Clear done", use tasks = tasks.filter((task) => !task.done), then save and render. For the empty message, check getVisibleTasks().length === 0 inside render() and append a special <li>.
Key takeaways
- Keep your data in an array and redraw the list with one
render()function after every change. - Build items with
createElementandtextContentso user input is always safe. - One listener on the list handles done and delete for every task, thanks to event delegation and
closest(). classList.toggle(name, condition)keeps classes in sync with your data.- Save to localStorage after every change and load with
try...catchfor a reliable app.