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.

8 min read

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.
Todo app data flow

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.

htmlhtml
<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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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:

javascriptjavascript
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.

javascriptjavascript
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

MistakeWhy it breaksFix
Storing tasks as plain stringsYou cannot track completed state or target one task for deletionStore each task as an object with an id, text, and completed field
Editing the DOM directly instead of the arrayThe page and the real data drift apart after a few changesChange the array first, then call one render function to redraw the page
Saving the raw array to storageStorage only accepts strings, so the saved value becomes unusable textConvert 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

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.
RunePowered by Rune AI

Frequently Asked Questions

Why does my todo list disappear when I refresh the page?

Tasks are only saved to localStorage if you write code that saves the array after every change. If that step is missing, the list only lives in memory and resets on refresh.

Can I store more than just task text in localStorage?

Yes. localStorage only stores strings, so store an array of task objects and convert it to a string before saving and back to an array after loading.

Why use an array of objects instead of an array of strings for tasks?

An object can hold the task text plus extra state like whether it is complete or a unique id, which you need to update or delete a single task without affecting the others.

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.