Skip to content
Happy Programming Guide
Start learning
Programming Projects

JavaScript Project: A To-Do List in the Browser

Build a to-do app with HTML, CSS and JavaScript that saves to local storage. Covers the DOM, events, event delegation and rendering from state.

This is the project that teaches the pattern behind every front-end app: keep your data in one place, and rebuild the page from it whenever it changes.

The HTML#

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>To-do</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="app">
    <h1>To-do</h1>

    <form id="add-form">
      <input id="new-task" type="text" placeholder="What needs doing?" required>
      <button type="submit">Add</button>
    </form>

    <p id="count"></p>
    <ul id="list"></ul>
  </main>

  <script src="app.js" defer></script>
</body>
</html>

defer means the script runs after the HTML is parsed, so the elements exist when your code looks for them. Without it you get “Cannot set properties of null”.

The CSS#

CSS
body {
  font-family: system-ui, sans-serif;
  background: #f7f8fb;
  margin: 0;
  padding: 40px 20px;
}

.app {
  max-width: 480px;
  margin: 0 auto;
  background: #fff;
  padding: 24px;
  border-radius: 14px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, .06);
}

#add-form { display: flex; gap: 8px; }
#new-task { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 8px; }

#list { list-style: none; padding: 0; }

#list li {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 4px;
  border-bottom: 1px solid #eee;
}

#list li.done span { text-decoration: line-through; opacity: .55; }
.delete { margin-left: auto; border: 0; background: none; cursor: pointer; }

Step 1: state and rendering#

Keep the data in an array. Never read the truth back out of the page.

JavaScript
let tasks = [];

const list = document.querySelector("#list");
const count = document.querySelector("#count");

function render() {
  list.innerHTML = "";

  for (const task of tasks) {
    const li = document.createElement("li");
    li.dataset.id = task.id;
    if (task.done) li.classList.add("done");

    const checkbox = document.createElement("input");
    checkbox.type = "checkbox";
    checkbox.checked = task.done;

    const label = document.createElement("span");
    label.textContent = task.text;        // safe: not parsed as HTML

    const remove = document.createElement("button");
    remove.className = "delete";
    remove.textContent = "×";
    remove.setAttribute("aria-label", "Delete " + task.text);

    li.append(checkbox, label, remove);
    list.appendChild(li);
  }

  const done = tasks.filter(t => t.done).length;
  count.textContent = tasks.length ? `${done} of ${tasks.length} done` : "Nothing yet.";
}

Step 2: adding a task#

JavaScript
const form = document.querySelector("#add-form");
const input = document.querySelector("#new-task");

form.addEventListener("submit", (event) => {
  event.preventDefault();               // stop the page reloading

  const text = input.value.trim();
  if (!text) return;

  tasks.push({ id: Date.now(), text, done: false });
  input.value = "";
  save();
  render();
});

Step 3: one listener for every row#

JavaScript
list.addEventListener("click", (event) => {
  const li = event.target.closest("li");
  if (!li) return;

  const id = Number(li.dataset.id);

  if (event.target.matches(".delete")) {
    tasks = tasks.filter(t => t.id !== id);
  } else if (event.target.matches("input[type=checkbox]")) {
    const task = tasks.find(t => t.id === id);
    task.done = !task.done;
  } else {
    return;
  }

  save();
  render();
});

One listener handles every row, including rows added later. Attaching a listener to each button individually would break the moment you add a new task. See JavaScript events explained.

Step 4: making it persist#

JavaScript
const KEY = "todo-tasks-v1";

function save() {
  localStorage.setItem(KEY, JSON.stringify(tasks));
}

function load() {
  try {
    tasks = JSON.parse(localStorage.getItem(KEY)) || [];
  } catch {
    tasks = [];
  }
}

load();
render();

localStorage only stores text, which is why everything goes through JSON.stringify and JSON.parse. The try block covers a corrupted value.

How the code works#

The whole app follows one loop: event → change the array → save → render. The page is always a picture of the array, never the source of truth. That is the same idea React and every other framework is built on, minus the framework.

Questions people ask#

Where does localStorage keep the data?

In the browser, tied to that site and that browser only. It does not sync between devices, and clearing site data removes it. For real syncing you need a server.

Is re-rendering the whole list slow?

Not at this size. Rebuilding a hundred rows is imperceptible. Frameworks optimise this for very large lists; you do not need to.

Why Date.now() for ids?

It is simple and unique enough here. crypto.randomUUID() is the better choice if items might be created in the same millisecond.

Where to go next#

Next projectBuild a JavaScript quiz app

Keep reading

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *