Skip to main content

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.

Result0px
New tab (opens in a new tab)
Download
<!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

  1. 01

    Use a <form> for adding tasks

    A <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. required makes 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>
  2. 02

    Announce the count with aria-live

    The <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>
  3. 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

  1. 04

    Strike through done tasks with :checked + label

    :checked matches a ticked checkbox, and + matches the element straight after another one. Together, input:checked + label styles 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

  1. 05

    Save tasks with localStorage and JSON

    localStorage is storage the browser keeps for your site, so data survives reloading or closing the tab. It only stores text, though, and tasks is an array of objects. JSON.stringify() turns the array into text to save, and JSON.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));
    }
  2. 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, its text, and whether it's done, and they all sit in the tasks array. The array is the source of truth: the page only shows it. It's let because deleting a task replaces it with a new array.

    // The tasks live in this array; the page is drawn from it.
    let tasks = loadTasks();
  3. 07

    Build elements with createElement

    document.createElement() makes a new element that isn't on the page yet. You set its properties, like type and checked, and append() puts elements inside one another. createTaskItem() builds each task as an <li> holding a checkbox, a label, and a Delete button. dataset.id stores 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;
    }
  4. 08

    Show typed text with textContent

    There are two common ways to put text in an element. innerHTML reads the text as HTML, so a task typed as <img onerror=...> would become a real image that runs code. textContent always shows exactly what was typed, as plain text. Anything a person types should go in with textContent.

    	// textContent, not innerHTML: whatever someone types shows as plain text.
    	label.textContent = task.text;
  5. 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 with createTaskItem. 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();
    }
  6. 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();
    });
  7. 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 change listener on the <ul> hear every checkbox inside it, including tasks added after the page loaded. event.target is the checkbox that changed, and find() uses its dataset.id to 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();
    });
  8. 12

    Delete with filter, then move focus

    filter() makes a new array of only the items its function returns true for. Here it keeps every task whose id doesn'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, so input.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.

  1. Challenge 01

    Add filters for All, Active, and Done.

  2. Challenge 02

    Let people edit a task by double-clicking it.

  3. Challenge 03

    Add a Clear done button that removes every finished task.

Finished To-do List?

Mark it done to track your progress.

You finished To-do List!

0 of 15 projects done.

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)