Project 10 · Intermediate
To-do List
A to-do list that remembers your tasks after you close the page. Add a task with Enter or the button, tick it off, delete it, and watch the count of tasks left update as you go.
- 3 HTML ideas
- 1 CSS idea
- 8 JavaScript ideas
Why build it
Keeping data in an array, drawing the page from it, and saving it in localStorage is everything you need for a shopping list, a reading list, a habit tracker, or a notes app that remembers everything after a reload. It's also how most frameworks think about pages, so the pattern carries straight over when you learn one.
Skills you'll earn
- Handle a form with JavaScript instead of reloading the page
- Keep an app's data in an array of objects and redraw the page from it
- Create elements from JavaScript safely with createElement and textContent
- Save data in the browser and load it again with localStorage and JSON
- Handle clicks on a list that keeps changing with one listener
01
Try it
Play with it first, then read the code that makes it work.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>To-do List</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="todo">
<h1 class="todo-title">To-do list</h1>
<!-- A <form> means Enter adds a task, as well as the button. -->
<form class="todo-form" id="todo-form">
<label class="todo-label" for="todo-input">New task</label>
<div class="todo-row">
<input
class="todo-input"
type="text"
id="todo-input"
maxlength="100"
autocomplete="off"
required
/>
<button type="submit" class="todo-add">Add</button>
</div>
</form>
<!-- The count is announced, so screen reader users hear each change. -->
<p class="todo-count" id="todo-count" aria-live="polite"></p>
<!-- JavaScript fills this list from the saved tasks. -->
<ul class="todo-list" id="todo-list"></ul>
<p class="todo-empty" id="todo-empty">
Nothing to do yet. Add your first task above.
</p>
</main>
<script src="script.js"></script>
</body>
</html>02
How it works
The ideas behind it, one at a time, each next to the code that does it.
HTMLthe structure
- 01
Use a
<form>for adding tasksA
<form>groups inputs with the button that sends them. Because the task input sits in a form, pressing Enter or clicking Add both submit it, with no extra key handling.requiredmakes the browser refuse to submit while the input is empty, and the<label>gives the field a name that screen readers read out.<!-- A <form> means Enter adds a task, as well as the button. --> <form class="todo-form" id="todo-form"> <label class="todo-label" for="todo-input">New task</label> <div class="todo-row"> <input class="todo-input" type="text" id="todo-input" maxlength="100" autocomplete="off" required /> <button type="submit" class="todo-add">Add</button> </div> </form> - 02
Announce the count with
aria-liveThe
<p>above the list shows how many tasks are left, like "2 tasks left", and JavaScript rewrites it whenever you add, tick, or delete a task. Sighted users see it change, but screen reader users wouldn't hear about it.aria-live="polite"tells screen readers to read the new text once the person pauses.<!-- The count is announced, so screen reader users hear each change. --> <p class="todo-count" id="todo-count" aria-live="polite"></p> - 03
Start with an empty list
The
<ul>has no items in the HTML because the tasks aren't known until the page loads: they come from what was saved last time. JavaScript draws one<li>per task into it. The message below the list is for when there are no tasks yet, and JavaScript hides it as soon as there's at least one.<!-- JavaScript fills this list from the saved tasks. --> <ul class="todo-list" id="todo-list"></ul> <p class="todo-empty" id="todo-empty"> Nothing to do yet. Add your first task above. </p>
CSSthe look
- 04
Strike through done tasks with
:checked + label:checkedmatches a ticked checkbox, and+matches the element straight after another one. Together,input:checked + labelstyles the label next to a ticked checkbox, here with grey text and a line through it. Ticking the box changes its state without any code, so the strike-through needs no JavaScript./* :checked + label strikes through a done task, no JavaScript needed. */ .todo-item input:checked + label { color: #9a9aa6; text-decoration: line-through; }
JavaScriptthe behaviour
- 05
Save tasks with
localStorageand JSONlocalStorageis storage the browser keeps for your site, so data survives reloading or closing the tab. It only stores text, though, andtasksis an array of objects.JSON.stringify()turns the array into text to save, andJSON.parse()turns that text back into an array when the page loads. With nothing saved yet,loadTasks()returns an empty array.const STORAGE_KEY = "todo-list"; // localStorage only stores text, so tasks are saved and loaded as JSON. function loadTasks() { const saved = localStorage.getItem(STORAGE_KEY); return saved ? JSON.parse(saved) : []; } function saveTasks() { localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks)); } - 06
Keep the tasks in an array
An array is an ordered list of values, and an object groups related values under names. Each task is an object with an
id, itstext, and whether it'sdone, and they all sit in thetasksarray. The array is the source of truth: the page only shows it. It'sletbecause deleting a task replaces it with a new array.// The tasks live in this array; the page is drawn from it. let tasks = loadTasks(); - 07
Build elements with
createElementdocument.createElement()makes a new element that isn't on the page yet. You set its properties, liketypeandchecked, andappend()puts elements inside one another.createTaskItem()builds each task as an<li>holding a checkbox, a label, and a Delete button.dataset.idstores the task's id on the checkbox and button, so later code knows which task was used.// Build one list item from one task. function createTaskItem(task) { const item = document.createElement("li"); item.className = "todo-item"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.id = `task-${task.id}`; checkbox.checked = task.done; checkbox.dataset.id = task.id; const label = document.createElement("label"); label.htmlFor = checkbox.id; // textContent, not innerHTML: whatever someone types shows as plain text. label.textContent = task.text; const deleteButton = document.createElement("button"); deleteButton.type = "button"; deleteButton.className = "todo-delete"; deleteButton.textContent = "Delete"; // Every button says "Delete", so the label adds which task it deletes. deleteButton.setAttribute("aria-label", `Delete "${task.text}"`); deleteButton.dataset.id = task.id; item.append(checkbox, label, deleteButton); return item; } - 08
Show typed text with
textContentThere are two common ways to put text in an element.
innerHTMLreads the text as HTML, so a task typed as<img onerror=...>would become a real image that runs code.textContentalways shows exactly what was typed, as plain text. Anything a person types should go in withtextContent.// textContent, not innerHTML: whatever someone types shows as plain text. label.textContent = task.text; - 09
Redraw the list from the array
map()makes a new array by running a function on every item, here turning each task into a list item withcreateTaskItem.replaceChildren()removes everything inside the list and puts the new items in.renderTasks()does both, so adding or deleting a task only means changing the array, then calling it to redraw.function renderTasks() { list.replaceChildren(...tasks.map(createTaskItem)); updateSummary(); } - 10
Handle the form with
preventDefault()When a form is submitted, the browser normally sends it and reloads the page, which would wipe what JavaScript is doing.
event.preventDefault()cancels that default behaviour, so the code can add the task instead.trim()removes spaces from both ends, so a task that's only spaces is ignored. After adding, the input is cleared and focused, ready for the next task.form.addEventListener("submit", (event) => { // Without this, the browser would reload the page to send the form. event.preventDefault(); const text = input.value.trim(); if (!text) return; // Date.now() makes a simple id that's different for every task. tasks.push({ id: Date.now(), text, done: false }); saveTasks(); renderTasks(); input.value = ""; input.focus(); }); - 11
Listen on the list, not each task
When you tick a checkbox, the event also travels up through every element that contains it, which is called bubbling. That lets one
changelistener on the<ul>hear every checkbox inside it, including tasks added after the page loaded.event.targetis the checkbox that changed, andfind()uses itsdataset.idto get the matching task.// One listener on the list hears every checkbox, even ones added later. list.addEventListener("change", (event) => { if (event.target.type !== "checkbox") return; const task = tasks.find((t) => t.id === Number(event.target.dataset.id)); task.done = event.target.checked; saveTasks(); // Only the count changes, so the list isn't rebuilt and focus stays put. updateSummary(); }); - 12
Delete with
filter, then move focusfilter()makes a new array of only the items its function returnstruefor. Here it keeps every task whoseiddoesn't match the deleted one.closest(".todo-delete")checks whether the click was on a Delete button. After the redraw, the button you pressed no longer exists, soinput.focus()moves focus back to the input instead of losing it.list.addEventListener("click", (event) => { const button = event.target.closest(".todo-delete"); if (!button) return; // filter keeps every task except the one being deleted. tasks = tasks.filter((task) => task.id !== Number(button.dataset.id)); saveTasks(); renderTasks(); // The button that had focus is gone, so send focus somewhere useful. input.focus(); });
03
Wrap up
Track your progress, then go deeper or keep going.
Take it further
Once it works, try these on your own. Each one uses what you've just learned, plus one new idea.
- Challenge 01
Add filters for All, Active, and Done.
- Challenge 02
Let people edit a task by double-clicking it.
- Challenge 03
Add a Clear done button that removes every finished task.
Finished To-do List?
Mark it done to track your progress.
Go deeper
The full article explains each idea in more depth, why the code is written this way, and more than fits on this page.
Article coming soon. See the series so far (opens in a new tab)