A to-do list is often the first real project a JavaScript learner builds, for a good reason: it pulls together almost everything you have practised separately — selecting elements, click events, arrays, browser storage — in one small app. By the end, you will have a page where you can add tasks, click one to cross it out, delete tasks, and close the browser without losing the list.
The finished app can:
- Add tasks with a button or the Enter key
- Cross out a finished task with one click
- Delete tasks you no longer need
- Count the tasks still open
- Save the list in the browser, so it survives refreshes
Before you start
You need only a web browser and a text editor — nothing to install, no Node.js or npm. If you can write a variable, a function, and a simple array, you know enough. The code uses a few modern features, such as arrow functions and the spread operator; if those look new, the guide to JavaScript ES6 features explains each one with examples.
Nine steps follow; each leaves your app doing something new and closes with a checkpoint.
Step 1: Create the three files
Create a folder named todo-app with three empty files inside: index.html (the page), style.css (how it looks), app.js (the behaviour) — separate files, as in real projects, each short enough to read in one sitting.
Checkpoint: your folder holds exactly those three files — app.js, not apps.js.
Step 2: Build the HTML skeleton
Type this into index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My To-Do List</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="app">
<h1>My To-Do List</h1>
<div class="add-row">
<input type="text" id="task-input" placeholder="What needs doing?">
<button type="button" id="add-btn">Add</button>
</div>
<p id="counter">0 tasks left</p>
<ul id="task-list"></ul>
<p id="empty-message">Nothing here yet. Add your first task above.</p>
</main>
<script src="app.js"></script>
</body>
</html>
Two details carry the whole project. The five id values — task-input, add-btn, counter, task-list, empty-message — are the hooks your JavaScript grabs in Step 4; they must match exactly. And the <script> tag sits at the end of <body>, so every element exists before app.js runs — moving it into <head> is the most common reason this project “does nothing” (see the errors below).
Checkpoint: open index.html in your browser: heading, input box, Add button, “0 tasks left”, and the empty-list message, all unstyled.
Step 3: Add the basic CSS
Type this into style.css:
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
background: #eef3f6;
color: #1d2b36;
}
.app {
max-width: 480px;
margin: 40px auto;
padding: 24px;
background: #ffffff;
border-radius: 10px;
}
h1 {
margin-top: 0;
}
.add-row {
display: flex;
gap: 8px;
}
#task-input {
flex: 1;
padding: 10px;
font-size: 16px;
border: 1px solid #b9c6cf;
border-radius: 6px;
}
#add-btn {
padding: 10px 18px;
font-size: 16px;
border: none;
border-radius: 6px;
background: #0f766e;
color: #ffffff;
cursor: pointer;
}
#counter,
#empty-message {
color: #52616d;
font-size: 14px;
}
#task-list {
list-style: none;
margin: 0;
padding: 0;
}
#task-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 6px;
border-bottom: 1px solid #e2e8ee;
}
#task-list li span {
cursor: pointer;
}
#task-list li.done span {
text-decoration: line-through;
color: #7c8a96;
}
.delete-btn {
border: none;
border-radius: 6px;
padding: 6px 10px;
background: #f1f5f8;
color: #9f2d2d;
cursor: pointer;
}
Most of this is simple layout: a centred card, a flexible row for input and button, one row per task. One rule matters later — #task-list li.done span draws the line through a finished task. Nothing uses done yet; Step 7 adds it, and the CSS never changes after this step.
Checkpoint: refresh — a tidy card now, though the button still does nothing.
Step 4: Grab the elements and set up the task array
Open app.js. First, a reference to each element, and the one array that drives the whole app:
const taskInput = document.getElementById('task-input');
const addBtn = document.getElementById('add-btn');
const taskList = document.getElementById('task-list');
const counter = document.getElementById('counter');
const emptyMessage = document.getElementById('empty-message');
let tasks = [];
getElementById finds an element by the exact id from Step 2 — one different letter and it finds nothing. The tasks array is the app’s single source of truth; each task is an object like { id: 1, text: 'Buy milk', done: false } — a unique id, the text you typed, and done for finished or not.
Checkpoint: nothing on the page changes yet — this step only prepares the pieces the next steps use.
Step 5: Draw the list with a render function
Instead of editing the page in several places, one function, renderTasks, redraws the list from the array. Whenever the array changes, the code calls it and the page catches up:
function renderTasks() {
taskList.innerHTML = '';
tasks.forEach((task) => {
const li = document.createElement('li');
if (task.done) {
li.classList.add('done');
}
const span = document.createElement('span');
span.textContent = task.text;
li.appendChild(span);
taskList.appendChild(li);
});
const remaining = tasks.filter((task) => !task.done).length;
counter.textContent =
remaining === 1 ? '1 task left' : remaining + ' tasks left';
emptyMessage.style.display = tasks.length === 0 ? 'block' : 'none';
}
renderTasks();
It clears the list, builds one <li> per task (adding done to finished ones), then updates the counter and empty-list message. The last line runs it once, drawing the starting state.
Checkpoint: refresh — “0 tasks left” plus the empty-list message means the render works on an empty array.
Step 6: Make the Add button work
Add an id counter, the addTask function, and the button listener:
let nextId = 1;
function addTask() {
const text = taskInput.value.trim();
if (text === '') {
return;
}
tasks.push({ id: nextId, text: text, done: false });
nextId++;
taskInput.value = '';
renderTasks();
taskInput.focus();
}
addBtn.addEventListener('click', addTask);
Put let nextId = 1; just under let tasks = [];. A plain counter guarantees a unique id — clock-based ids can repeat within the same millisecond, and then marking one task done would mark both. Notice two habits: .trim() plus the early return means blank input adds nothing, and renderTasks() runs after the array changes — data first, then redraw.
Checkpoint: type a task, click Add — it appears, the counter rises. Spaces alone add nothing. Refresh and the tasks vanish: memory only, until Step 9.
Step 7: Click a task to mark it complete
Inside renderTasks, add one line under span.textContent = task.text;:
span.addEventListener('click', () => toggleTask(task.id));
Then add the toggleTask function near addTask:
function toggleTask(id) {
tasks = tasks.map((task) =>
task.id === id ? { ...task, done: !task.done } : task
);
renderTasks();
}
map returns a new array: the clicked task is copied with done flipped (the spread, ...task, carries its other values), and the rest pass through untouched.
Checkpoint: add two tasks, click the first one’s text — it crosses out (the Step 3 .done rule, in use at last) and the counter drops. Click again: back again.
Step 8: Add a delete button to every task
Inside renderTasks, add this block after the span.addEventListener(...) line:
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.textContent = 'Delete';
deleteBtn.classList.add('delete-btn');
deleteBtn.addEventListener('click', () => deleteTask(task.id));
Change the append lines so the button joins the row:
li.appendChild(span);
li.appendChild(deleteBtn);
And add deleteTask — filter keeps every task except the clicked one:
function deleteTask(id) {
tasks = tasks.filter((task) => task.id !== id);
renderTasks();
}
Checkpoint: add three tasks, delete the middle one — only it disappears, and the counter updates.
Step 9: Save the list in localStorage
localStorage is a small storage area built into every browser; text saved there stays after the tab closes. It stores strings only, so the array travels as JSON — JSON.stringify out, JSON.parse back. Add the two storage functions:
function loadTasks() {
try {
const saved = JSON.parse(localStorage.getItem('todo-tasks'));
return Array.isArray(saved) ? saved : [];
} catch (error) {
return [];
}
}
function saveTasks() {
localStorage.setItem('todo-tasks', JSON.stringify(tasks));
}
Four small edits connect them:
- Change
let tasks = [];tolet tasks = loadTasks();, so the app starts from the saved list. - Change
let nextId = 1;to the line below, so new ids start one above the largest saved id:
let nextId = tasks.reduce((highest, task) => Math.max(highest, task.id), 0) + 1;
- In
addTask,toggleTask, anddeleteTask, addsaveTasks();just before eachrenderTasks();. - Add the Enter-key listener at the bottom of the file:
taskInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
addTask();
}
});
The try/catch covers the first visit (nothing saved) and any damaged saved value: the app starts empty instead of stopping with an error.
Checkpoint: add two tasks, cross one out, refresh — both remain, the finished one still crossed out. Close and reopen the tab: still there.
The complete code in one place
Your index.html and style.css should match Steps 2 and 3 exactly. Here is the finished app.js in full:
const taskInput = document.getElementById('task-input');
const addBtn = document.getElementById('add-btn');
const taskList = document.getElementById('task-list');
const counter = document.getElementById('counter');
const emptyMessage = document.getElementById('empty-message');
let tasks = loadTasks();
let nextId = tasks.reduce((highest, task) => Math.max(highest, task.id), 0) + 1;
function loadTasks() {
try {
const saved = JSON.parse(localStorage.getItem('todo-tasks'));
return Array.isArray(saved) ? saved : [];
} catch (error) {
return [];
}
}
function saveTasks() {
localStorage.setItem('todo-tasks', JSON.stringify(tasks));
}
function renderTasks() {
taskList.innerHTML = '';
tasks.forEach((task) => {
const li = document.createElement('li');
if (task.done) {
li.classList.add('done');
}
const span = document.createElement('span');
span.textContent = task.text;
span.addEventListener('click', () => toggleTask(task.id));
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.textContent = 'Delete';
deleteBtn.classList.add('delete-btn');
deleteBtn.addEventListener('click', () => deleteTask(task.id));
li.appendChild(span);
li.appendChild(deleteBtn);
taskList.appendChild(li);
});
const remaining = tasks.filter((task) => !task.done).length;
counter.textContent =
remaining === 1 ? '1 task left' : remaining + ' tasks left';
emptyMessage.style.display = tasks.length === 0 ? 'block' : 'none';
}
function addTask() {
const text = taskInput.value.trim();
if (text === '') {
return;
}
tasks.push({ id: nextId, text: text, done: false });
nextId++;
taskInput.value = '';
saveTasks();
renderTasks();
taskInput.focus();
}
function toggleTask(id) {
tasks = tasks.map((task) =>
task.id === id ? { ...task, done: !task.done } : task
);
saveTasks();
renderTasks();
}
function deleteTask(id) {
tasks = tasks.filter((task) => task.id !== id);
saveTasks();
renderTasks();
}
addBtn.addEventListener('click', addTask);
taskInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
addTask();
}
});
renderTasks();
4 common errors and how to fix them
1. “Cannot read properties of null”, and nothing works. app.js ran before the elements existed — the <script> tag sits in <head>, or above the content — so getElementById returned null. Move it to the end of <body> (Step 2). Same family as the guide to cannot read properties of undefined: the code reached for something not there yet.
2. Clicking Add does nothing at all. Compare the id spellings in both files character by character, then check that src="app.js" matches your file’s real name.
3. Tasks disappear on refresh. Step 9 is not fully connected: the file top must say let tasks = loadTasks();, all three action functions must call saveTasks();, and you must reopen the same index.html — another folder’s copy has separate storage.
4. The list breaks after you experimented with the saved data. Stale data in an old shape is still stored. Open the console (F12), run localStorage.removeItem('todo-tasks'), refresh — the app starts empty.
Ideas to extend your to-do app
Five small, self-contained upgrades once the basic version works:
- Edit a task: double-click a task to turn its text into an input, saved on Enter.
- Clear completed: one button that removes every
done: truetask — a singlefiltercall. - Filters: All / Still open / Done buttons that change what
renderTasksdraws, not the stored list. - Due dates: a date input beside the task input, stored as a fourth property on each task.
- A finished counter: “2 of 5 finished” beside the open counter — the same
filteridea.
For your next build, the list of coding projects for beginners has more projects at a similar level.
Frequently asked questions
Why build this in plain JavaScript instead of a framework like React?
Frameworks rest on these same ideas: data in one place (the array), the screen drawn from that data (the render function), data changed in response to events. The plain version makes framework code easier to read later — you can see what it does for you.
Where is my task data actually stored?
In that browser’s localStorage, on that device. It is never sent to a server and does not sync — tasks saved in Chrome will not appear in Firefox, and clearing site data deletes them. Fine for a personal list; apps that follow you across devices use a server instead.
Can I show this project to others?
Yes — the three files are the whole app, so you can share the folder or put them on any static hosting (GitHub Pages, for example) and send the link. Be ready to explain the two ideas people ask about most: why the list is redrawn from the array, and how the save-and-load step works.
You now have a complete app in three small files — and a pattern that returns in almost every JavaScript project: one array holding the state, one function drawing the screen, small functions changing the state on clicks and keys.




