Overview
A to-do list is the classic first "real" JavaScript project because it touches every core DOM skill at once: reading user input, creating and removing elements, responding to clicks without writing a separate listener for every button, and keeping a page's visible state in sync with an underlying array of data. Nothing about it depends on a framework or a build step — it is plain JavaScript running directly in the browser.
By the end of this tutorial you will have a task manager where the visible list is always redrawn from a single JavaScript array called `tasks`. You will add new tasks through a form, mark tasks complete by clicking them, delete tasks with a button, and save every change to `localStorage` so the list survives a page refresh.
- A form that adds a new task to an in-memory `tasks` array without reloading the page.
- A `renderTasks()` function that rebuilds the visible list from that array every time it changes.
- Click-to-complete behavior using a single delegated event listener instead of one listener per task.
- A delete button on every task that removes just that task from the array.
- Automatic saving to and loading from `localStorage`, so tasks persist across page reloads.
Prerequisites
- DOM basics — `document.getElementById`/`querySelector`, `createElement`, `appendChild`, and `textContent`.
- Events — `addEventListener`, the `event` object, and `event.preventDefault()`.
- Arrays — `push`, `filter`, `find`, and `forEach`.
- Objects — creating plain objects with `{ key: value }` and reading/writing their properties.
- JSON basics — `JSON.stringify` and `JSON.parse`, used with `localStorage`.
Project Structure
This project uses three files: `index.html` for the minimal page structure, `styles.css` for just enough styling to make the list readable, and `script.js` for all of the behavior. The HTML and CSS are introduced once here and barely change again — every step from here on only adds JavaScript.
The design follows a single rule that makes the whole app predictable: the `tasks` array is the one source of truth, and the visible `<ul>` is always thrown away and rebuilt from that array whenever it changes. This is simpler than trying to carefully add, remove, or edit individual `<li>` elements by hand, and it means a bug in the displayed list is always really a bug in the array.
<!-- Root container for the whole to-do app --><div class="todo-app"> <h1>My Tasks</h1>
<!-- A <form>, not just a lone button, so pressing Enter submits too --> <form id="todo-form"> <input type="text" id="todo-input" placeholder="What do you need to do?" autocomplete="off" /> <button type="submit">Add</button> </form>
<!-- Starts empty on purpose; renderTasks() fills this in from tasks[] --> <ul id="todo-list"></ul>
<!-- Shown/hidden by JS based on whether tasks[] is empty --> <p id="empty-message">No tasks yet. Add one above to get started!</p></div>Every element the script needs to touch has an `id` (`todo-form`, `todo-input`, `todo-list`, `empty-message`) so it can be grabbed once with `getElementById` and reused everywhere.
/* Reset default spacing so the layout below is predictable */* { box-sizing: border-box; margin: 0; padding: 0;}
body { font-family: Arial, Helvetica, sans-serif; /* Available on every OS, no web font to load */ background: #1a1d23; /* Dark background to match the rest of the site */ color: #e8e8e8; display: flex; justify-content: center; padding: 40px 16px;}
.todo-app { width: 100%; max-width: 420px; background: #23262e; border-radius: 10px; padding: 24px;}
.todo-app h1 { margin-bottom: 16px; font-size: 1.5rem;}
#todo-form { display: flex; gap: 8px; margin-bottom: 16px;}
#todo-input { flex: 1; /* Input grows to fill the space; the button keeps its natural width */ padding: 10px 12px; border-radius: 6px; border: 1px solid #3a3f4b; background: #1a1d23; color: #e8e8e8; font-size: 1rem;}
#todo-form button { padding: 10px 16px; border: none; border-radius: 6px; background: #4f8cff; color: white; cursor: pointer; font-weight: bold;}
#todo-list { list-style: none; /* We draw our own row layout instead of using bullet points */}
.todo-item { display: flex; align-items: center; gap: 10px; padding: 10px 4px; border-bottom: 1px solid #3a3f4b;}
.todo-item.completed .todo-text { text-decoration: line-through; /* Visual cue that a task is finished */ opacity: 0.6;}
.todo-text { flex: 1; cursor: pointer; /* Clicking the text (not just a checkbox) toggles completion */}
.delete-btn { background: transparent; border: none; color: #ff6b6b; cursor: pointer; font-size: 1.1rem; line-height: 1;}
#empty-message { text-align: center; color: #888; padding: 12px 0;}
.hidden { display: none; /* Toggled from JS instead of adding/removing the element */}With the shell in place, every remaining step edits only `script.js`. Nothing above this line will change again.
Step 1: Select Elements and Set Up State
The script starts by grabbing every element it will need, once, and declaring the `tasks` array that holds the actual data. Every task is a plain object with an `id`, its `text`, and a `completed` flag — the DOM never stores data of its own, it only reflects whatever is in this array.
// Grab every DOM element the script reads from or writes to, once, so// later functions don't repeat document.getElementById callsconst todoForm = document.getElementById('todo-form');const todoInput = document.getElementById('todo-input');const todoList = document.getElementById('todo-list');const emptyMessage = document.getElementById('empty-message');
// The single source of truth for the app. The DOM is always rebuilt FROM// this array — no code ever edits an <li> directly by hand.let tasks = [];
// Hands out a unique id to each new task without needing a database;// bumped up past any ids loaded from storage in Step 5let nextId = 1;Keeping `tasks` as plain data (not DOM nodes) is what makes saving to `localStorage` possible later — `JSON.stringify` can serialize an array of objects, but it has no idea what to do with a live DOM element.
Step 2: Render Tasks to the DOM
`renderTasks()` is the one function responsible for what the user actually sees. It clears the list and rebuilds it from `tasks`, so calling it after any change (add, complete, delete) is enough to keep the page correct — there is never a need to add or remove a single `<li>` by hand.
// Rebuilds the <ul> from scratch based on the current tasks array. Doing a// full rebuild instead of patching individual nodes is simpler and far less// error-prone for a list this size.function renderTasks() { todoList.innerHTML = ''; // Clear out whatever was rendered last time
// Show the "No tasks yet" message only when the array is actually empty emptyMessage.classList.toggle('hidden', tasks.length > 0);
tasks.forEach(function (task) { const li = document.createElement('li'); // One <li> per task object li.className = 'todo-item' + (task.completed ? ' completed' : ''); // completed class drives the strike-through style li.dataset.id = task.id; // Stash the id on the element so click handlers can look the task back up
const span = document.createElement('span'); span.className = 'todo-text'; span.textContent = task.text; // textContent (not innerHTML) treats task text as plain text, never as markup
const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; // Prevents it from also acting as the form's submit button deleteBtn.className = 'delete-btn'; deleteBtn.textContent = '\u2715'; // A plain "x" glyph, no icon library needed
li.appendChild(span); li.appendChild(deleteBtn); todoList.appendChild(li); // Add the finished row to the visible list });}`dataset.id` writes the task's id into a `data-id="..."` HTML attribute on the `<li>`. Reading `li.dataset.id` back later is how click handlers figure out which task in the array a given row corresponds to, without storing a reference to the DOM node itself.
Step 3: Add a New Task
Adding a task listens for the form's `submit` event rather than a click on the button directly — that way, both clicking "Add" and pressing Enter while the input is focused trigger the same code, which is the standard behavior users expect from a form.
// Fires when the form is submitted, whether by clicking "Add" or pressing EntertodoForm.addEventListener('submit', function (event) { event.preventDefault(); // Stop the browser's default full-page reload on form submit
const text = todoInput.value.trim(); // trim() so a string of only spaces doesn't create a blank task if (text === '') { return; // Silently ignore empty submissions }
tasks.push({ id: nextId++, // Use the current value as this task's id, then increment for the next one text: text, completed: false, });
todoInput.value = ''; // Clear the input so the next task can be typed immediately saveTasks(); // Defined in Step 5 — persists the updated array renderTasks(); // Redraw the list so the new task actually appears});Click Run to see what this code prints.
Step 4: Complete and Delete Tasks
Rather than attaching a click listener to every individual task (which would miss any task added later), a single listener on the parent `<ul>` catches every click inside it. This pattern is called event delegation, and it relies on `event.target` to figure out exactly what was clicked.
// One listener on the shared parent handles clicks for every task, including// ones created after this listener was attachedtodoList.addEventListener('click', function (event) { const li = event.target.closest('.todo-item'); // Walk up to the row that was clicked, wherever inside it if (!li) return; // The click landed outside any task row entirely
const id = Number(li.dataset.id); // dataset values are always strings, so convert back to a number const task = tasks.find(function (t) { return t.id === id; }); if (!task) return;
if (event.target.classList.contains('delete-btn')) { tasks = tasks.filter(function (t) { return t.id !== id; }); // Keep every task except the deleted one } else if (event.target.classList.contains('todo-text')) { task.completed = !task.completed; // Flip the completed flag on the matching task object } else { return; // Click was on the row but not on text or the delete button — ignore it }
saveTasks(); renderTasks();});`closest('.todo-item')` matters because a click on the delete button's text technically has `event.target` set to the `<button>`, not the `<li>` — `closest` walks up the DOM tree from whatever was actually clicked until it finds an ancestor matching the selector, so the id lookup always works regardless of which exact element inside the row was clicked.
Step 5: Persist Tasks with localStorage
`localStorage` can only store strings, so saving the `tasks` array means converting it to a JSON string with `JSON.stringify` first, and loading it back means parsing that string with `JSON.parse`. Both helpers below are called from the code already written in Steps 3 and 4.
const STORAGE_KEY = 'todo-app-tasks'; // One key everything reads/writes, kept in one place to avoid typos
// Saves the current tasks array to localStorage as a JSON stringfunction saveTasks() { localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));}
// Reads the saved JSON string back out and parses it into a real array again.// Runs once on startup, before the very first render.function loadTasks() { const saved = localStorage.getItem(STORAGE_KEY); // Returns null if nothing was ever saved if (!saved) return; // Nothing to restore — tasks stays the empty array from Step 1
tasks = JSON.parse(saved); // nextId must continue past the highest id already saved, otherwise a // freshly reloaded page could hand out an id that collides with an existing task nextId = tasks.reduce(function (max, t) { return Math.max(max, t.id); }, 0) + 1;}Click Run to see what this code prints.
Step 6: Wire Everything Together
The very last lines of the script run once, immediately, when the page loads: pull any saved tasks out of `localStorage`, then draw the list for the first time before the user has clicked anything.
// Runs once when the script first loads: restore any saved tasks, then// draw the initial list before the user interacts with the page at allloadTasks();renderTasks();Complete Code
Here is the complete project — the HTML shell, the CSS, and the fully assembled JavaScript in the order you would put them in real files (`index.html`, `styles.css`, `script.js`).
<!-- Root container for the whole to-do app --><div class="todo-app"> <h1>My Tasks</h1>
<!-- A <form>, not just a lone button, so pressing Enter submits too --> <form id="todo-form"> <input type="text" id="todo-input" placeholder="What do you need to do?" autocomplete="off" /> <button type="submit">Add</button> </form>
<!-- Starts empty on purpose; renderTasks() fills this in from tasks[] --> <ul id="todo-list"></ul>
<!-- Shown/hidden by JS based on whether tasks[] is empty --> <p id="empty-message">No tasks yet. Add one above to get started!</p></div>* { box-sizing: border-box; margin: 0; padding: 0;}
body { font-family: Arial, Helvetica, sans-serif; background: #1a1d23; color: #e8e8e8; display: flex; justify-content: center; padding: 40px 16px;}
.todo-app { width: 100%; max-width: 420px; background: #23262e; border-radius: 10px; padding: 24px;}
.todo-app h1 { margin-bottom: 16px; font-size: 1.5rem;}
#todo-form { display: flex; gap: 8px; margin-bottom: 16px;}
#todo-input { flex: 1; padding: 10px 12px; border-radius: 6px; border: 1px solid #3a3f4b; background: #1a1d23; color: #e8e8e8; font-size: 1rem;}
#todo-form button { padding: 10px 16px; border: none; border-radius: 6px; background: #4f8cff; color: white; cursor: pointer; font-weight: bold;}
#todo-list { list-style: none;}
.todo-item { display: flex; align-items: center; gap: 10px; padding: 10px 4px; border-bottom: 1px solid #3a3f4b;}
.todo-item.completed .todo-text { text-decoration: line-through; opacity: 0.6;}
.todo-text { flex: 1; cursor: pointer;}
.delete-btn { background: transparent; border: none; color: #ff6b6b; cursor: pointer; font-size: 1.1rem; line-height: 1;}
#empty-message { text-align: center; color: #888; padding: 12px 0;}
.hidden { display: none;}const todoForm = document.getElementById('todo-form');const todoInput = document.getElementById('todo-input');const todoList = document.getElementById('todo-list');const emptyMessage = document.getElementById('empty-message');
const STORAGE_KEY = 'todo-app-tasks';
let tasks = [];let nextId = 1;
function saveTasks() { localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));}
function loadTasks() { const saved = localStorage.getItem(STORAGE_KEY); if (!saved) return;
tasks = JSON.parse(saved); nextId = tasks.reduce(function (max, t) { return Math.max(max, t.id); }, 0) + 1;}
function renderTasks() { todoList.innerHTML = '';
emptyMessage.classList.toggle('hidden', tasks.length > 0);
tasks.forEach(function (task) { const li = document.createElement('li'); li.className = 'todo-item' + (task.completed ? ' completed' : ''); li.dataset.id = task.id;
const span = document.createElement('span'); span.className = 'todo-text'; span.textContent = task.text;
const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'delete-btn'; deleteBtn.textContent = '\u2715';
li.appendChild(span); li.appendChild(deleteBtn); todoList.appendChild(li); });}
todoForm.addEventListener('submit', function (event) { event.preventDefault();
const text = todoInput.value.trim(); if (text === '') { return; }
tasks.push({ id: nextId++, text: text, completed: false, });
todoInput.value = ''; saveTasks(); renderTasks();});
todoList.addEventListener('click', function (event) { const li = event.target.closest('.todo-item'); if (!li) return;
const id = Number(li.dataset.id); const task = tasks.find(function (t) { return t.id === id; }); if (!task) return;
if (event.target.classList.contains('delete-btn')) { tasks = tasks.filter(function (t) { return t.id !== id; }); } else if (event.target.classList.contains('todo-text')) { task.completed = !task.completed; } else { return; }
saveTasks(); renderTasks();});
loadTasks();renderTasks();Sample Run
Click Run to see what this code prints.
Extend This Project
- Add an "Edit" mode that turns a task's text into an `<input>` in place, saving the new value on blur or Enter.
- Add filter buttons (All / Active / Completed) that hide rows in `renderTasks()` based on a `filter` state variable.
- Add a "Clear Completed" button that filters every completed task out of the array in one click.
- Show a due date on each task using an `<input type="date">` in the form, stored as another field on the task object.
- Replace the alphabetical add order with drag-and-drop reordering using the HTML Drag and Drop API.
Summary
You built a complete, persistent To-Do List using nothing but the DOM, event delegation, and localStorage. The pattern at the center of it — keep one array as the source of truth, re-render the DOM from that array, and save the array on every change — is a pattern you will reuse in almost every interactive JavaScript project you build next, including the other three projects in this course.