Build a JavaScript Todo List App: Complete Guide
Build a working todo list app with plain JavaScript, covering adding tasks, marking them complete, deleting them, and saving them with localStorage.
A JavaScript todo list app lets a user type a task, add it to a list, mark it done, and remove it, with the list still there after the page reloads. It is a common beginner project because it introduces a pattern most real apps use: keep the real data in JavaScript, then redraw the page from that data every time something changes.
You will build a page with a text input, an add button, and a list. Typing a task and pressing the button adds it to the list.
Clicking a task toggles it as done, and a delete button removes it. The list is saved with localStorage, so closing and reopening the page keeps every task.
What You Will Build
The app needs four pieces of behavior:
- Add a new task from a text input.
- Mark a task as complete by clicking it.
- Delete a task with a button next to it.
- Keep the list saved between page visits.
Every action in this app changes one array of task objects first, then the page redraws from that array. Saving to storage happens right after the array changes, so the two never fall out of sync.
That single array is the source of truth for everything the app shows on screen.
Step 1: Build the HTML
Start with an input, an add button, and an empty list that JavaScript will fill in.
<input type="text" id="taskInput" placeholder="Add a task" />
<button id="addButton">Add</button>
<ul id="taskList"></ul>The list starts empty on purpose. Instead of writing list items by hand, JavaScript will build each list entry from the tasks array whenever the list changes. Adding and removing items this way, with a method covered in the array push and pop guide, keeps the array as the single source of truth.
Step 2: Store Tasks as an Array of Objects
Before writing any DOM code, decide how a task is represented in memory.
const taskInput = document.getElementById("taskInput");
const addButton = document.getElementById("addButton");
const taskList = document.getElementById("taskList");
let tasks = [];Each task will be an object with a task id, the task text, and a completed flag, rather than a plain string. The id lets you find one exact task later. The completed flag tracks whether it shows a strikethrough, without either piece of information needing to be read back out of the page.
Step 3: Render the List from the Array
Write one function that clears the list and rebuilds it from the current tasks array. Every other function will call this one after it makes a change.
function createTaskItem(task) {
const item = document.createElement("li");
item.dataset.id = task.id;
const text = document.createElement("span");
text.textContent = task.text;
text.dataset.action = "toggle";
text.style.textDecoration = task.completed ? "line-through" : "none";
const deleteButton = document.createElement("button");
deleteButton.textContent = "Delete";
deleteButton.dataset.action = "delete";
item.append(text, deleteButton);
return item;
}This helper builds one list item with a clickable text span and a delete button. Both elements carry a data-action attribute so one listener can tell a toggle click from a delete click.
function renderTasks() {
taskList.innerHTML = "";
tasks.forEach((task) => taskList.appendChild(createTaskItem(task)));
}renderTasks throws away the old list markup and appends a fresh item for every task in the array, built by the helper above.
taskList.addEventListener("click", (event) => {
const id = Number(event.target.closest("li")?.dataset.id);
if (event.target.dataset.action === "toggle") toggleTask(id);
if (event.target.dataset.action === "delete") deleteTask(id);
});This single listener on the list container reads which task and which action a click belongs to, the same event delegation pattern used later in a shopping cart or notes app. Rebuilding the whole list on every change keeps the page and the array from ever drifting apart.
Step 4: Add, Toggle, and Delete Tasks
The add function only needs to update the array, save it, then call the render function from step 3.
function addTask(text) {
tasks.push({ id: Date.now(), text, completed: false });
saveTasks();
renderTasks();
}
addButton.addEventListener("click", () => {
const text = taskInput.value.trim();
if (text === "") return;
addTask(text);
taskInput.value = "";
});The add function pushes a new object onto the array using the current time as a simple unique id, saves it, then re-renders. The click handler reads the input, ignores empty submissions, and clears the box after adding. saveTasks is defined in the next step, but JavaScript hoists function declarations, so this call already works once the whole script runs.
Toggling and deleting a task follow the same shape, just with a different array update:
function toggleTask(id) {
tasks = tasks.map((task) =>
task.id === id ? { ...task, completed: !task.completed } : task
);
saveTasks();
renderTasks();
}
function deleteTask(id) {
tasks = tasks.filter((task) => task.id !== id);
saveTasks();
renderTasks();
}Toggling builds a new array where only the matching task has its completed value flipped. Deleting filters the matching task out entirely. Both save the array and re-render afterward, so the page, the array, and storage never fall out of sync.
Step 5: Save Tasks with Storage
The list would otherwise reset on every page reload because the array only lives in memory. Defining the save and load functions is what makes the calls above actually persist the list.
function saveTasks() {
localStorage.setItem("tasks", JSON.stringify(tasks));
}
function loadTasks() {
const saved = localStorage.getItem("tasks");
tasks = saved ? JSON.parse(saved) : [];
renderTasks();
}
loadTasks();Storage only holds strings, so the save function converts the array to a string before saving it. The load function reverses that conversion and runs once when the script starts, so tasks from a previous visit appear immediately. See the JS localStorage API guide for more on reading and writing values this way.
Common Mistakes
| Mistake | Why it breaks | Fix |
|---|---|---|
| Storing tasks as plain strings | You cannot track completed state or target one task for deletion | Store each task as an object with an id, text, and completed field |
| Editing the DOM directly instead of the array | The page and the real data drift apart after a few changes | Change the array first, then call one render function to redraw the page |
| Saving the raw array to storage | Storage only accepts strings, so the saved value becomes unusable text | Convert the array to a string before saving and back after loading |
Next Step
The array-then-render pattern used here scales directly to other list-based projects, including a JavaScript shopping cart. If you have not built the simpler JS counter app yet, it covers the click and update basics this project builds on.
Rune AI
Key Insights
- Store tasks as an array of objects, each with an id, text, and completed state, not as plain strings.
- Re-render the whole list from the array after every change instead of editing the page by hand.
- Use a unique id per task so you can find and update or delete exactly one item.
- localStorage only stores strings, so convert the array to a string before saving and back after loading.
- Load saved tasks when the page starts so the list survives a refresh.
Frequently Asked Questions
Why does my todo list disappear when I refresh the page?
Can I store more than just task text in localStorage?
Why use an array of objects instead of an array of strings for tasks?
Conclusion
A todo list app combines the same DOM basics as a counter app with two new skills: managing a list of items instead of one value, and saving that list so it survives a page refresh. Once you can add, update, delete, and persist a list of objects, you have the core skill set behind most data-driven JavaScript apps.
More in this topic
Using Reflect and Proxy Together in JavaScript
Proxy traps intercept operations, but Reflect is what makes them behave correctly. Learn why Reflect belongs inside every trap and what breaks when you skip it.
Top JS Array Methods Interview Questions to Know
The array method questions that come up most often in JavaScript interviews, answered directly with short examples: map vs forEach, mutating vs non-mutating methods, reduce, and more.
JavaScript Reflect API: Advanced Architecture
The Reflect object exposes JavaScript's own internal operations as plain functions. Learn every Reflect method, what it returns, and why it exists as its own API.