Intermediate2 hoursHTMLCSSJavaScript

Build a To-Do List App with JavaScript (and localStorage)

Add, complete, delete and filter tasks, saved in the browser so they survive a refresh.

What you'll build

The to-do app is the "hello world" of real applications, and for good reason. It teaches the pattern behind almost every modern interface:

  1. Keep your data (the tasks) in an array.
  2. Write one render function that draws the page from that data.
  3. When the user does something, update the data, save it, and render again.

Master this and frameworks like React will feel familiar.

Data first, page second

The biggest idea in this project is that the page is a picture of your data. Instead of adding and removing list items by hand every time something happens, you keep one array of task objects and a single render() function that redraws the list from that array.

When the user adds, ticks or deletes a task, you never touch the list directly. You change the array, save it and call render() again. That might sound wasteful, but it removes a whole family of bugs. The list on screen can never disagree with your data, because it's always rebuilt from it.

Each task is a small object:

JavaScript
{ id: 1712345678901, text: "Buy milk", done: false }

The id lets you find the right task later, even if two tasks have the same text. Using Date.now() is a simple way to get a unique number for a small app like this. If objects and arrays still feel shaky, the JavaScript objects and array methods lessons are worth a quick revisit.

How localStorage keeps your tasks

Normally, everything in a web page disappears when you refresh. localStorage is a small storage area the browser keeps for each website, and it survives refreshes and restarts.

It can only store strings, so the app converts the array to text with JSON.stringify before saving and back into an array with JSON.parse when loading. The ?? [] part means "if nothing has been saved yet, start with an empty list". The localStorage lesson and the JSON lesson explain both steps.

Keep in mind that this data lives only in one browser on one device. If you open the app on your phone, your laptop's tasks won't be there. Syncing between devices needs a server and a database, which is what the Node.js course teaches.

Requirements checklist

  • Add a task with a form (Enter key works, empty tasks are ignored)
  • Click a task's checkbox to mark it done (crossed out)
  • Delete a task with a ✕ button
  • Filter: All / Active / Done
  • A counter: "3 tasks left"
  • Tasks are saved in localStorage and reload on refresh

Starter code

Press Run to see where you're starting from, then follow the steps.

// state, render, events…
ResultEdit the code, then press Run or Ctrl+Enter

Step-by-step guide

  1. 1

    Set up the state

    Each task is an object with an id, text and done. Load saved tasks, or start empty.

    JavaScript
    let tasks = JSON.parse(localStorage.getItem("tasks")) ?? [];
    let filter = "all";
    
    const form = document.querySelector("#form");
    const input = document.querySelector("#input");
    const list = document.querySelector("#list");
    const count = document.querySelector("#count");
    
    function save() {
      localStorage.setItem("tasks", JSON.stringify(tasks));
    }
  2. 2

    Render the list from data

    render throws away the old list and rebuilds it from tasks. Simple and reliable.

    JavaScript
    function render() {
      const visible = tasks.filter((t) =>
        filter === "all" ? true : filter === "done" ? t.done : !t.done
      );
    
      list.replaceChildren();
      for (const task of visible) {
        const li = document.createElement("li");
        li.dataset.id = task.id;
        li.className = task.done ? "done" : "";
        li.innerHTML = `<label><input type="checkbox" ${task.done ? "checked" : ""}> <span></span></label>
                        <button class="delete" aria-label="Delete task">✕</button>`;
        li.querySelector("span").textContent = task.text; // safe: textContent for user text
        list.append(li);
      }
    
      const left = tasks.filter((t) => !t.done).length;
      count.textContent = `${left} task${left === 1 ? "" : "s"} left`;
    }

    We use innerHTML only for our own fixed markup, and textContent for the user's text, so nobody can inject HTML.

  3. 3

    Add tasks

    JavaScript
    form.addEventListener("submit", (e) => {
      e.preventDefault();
      const text = input.value.trim();
      if (!text) return;
      tasks.push({ id: Date.now(), text, done: false });
      input.value = "";
      save();
      render();
    });
  4. 4

    Complete and delete with event delegation

    One listener on the list handles every task, even ones added later.

    JavaScript
    list.addEventListener("click", (e) => {
      const li = e.target.closest("li");
      if (!li) return;
      const id = Number(li.dataset.id);
    
      if (e.target.matches(".delete")) {
        tasks = tasks.filter((t) => t.id !== id);
      } else if (e.target.matches("input[type=checkbox]")) {
        const task = tasks.find((t) => t.id === id);
        task.done = e.target.checked;
      } else {
        return;
      }
      save();
      render();
    });
  5. 5

    Filters

    JavaScript
    document.querySelectorAll("[data-filter]").forEach((btn) => {
      btn.addEventListener("click", () => {
        filter = btn.dataset.filter;
        document.querySelectorAll("[data-filter]").forEach((b) => b.classList.toggle("active", b === btn));
        render();
      });
    });
    
    render();

Final solution

Show the complete solution
let tasks = JSON.parse(localStorage.getItem("tasks")) ?? [];
let filter = "all";

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

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

function render() {
  const visible = tasks.filter((t) =>
    filter === "all" ? true : filter === "done" ? t.done : !t.done
  );

  list.replaceChildren();
  for (const task of visible) {
    const li = document.createElement("li");
    li.dataset.id = task.id;
    li.className = task.done ? "done" : "";
    li.innerHTML = `<label><input type="checkbox" ${task.done ? "checked" : ""}> <span></span></label>
                    <button class="delete" aria-label="Delete task">✕</button>`;
    li.querySelector("span").textContent = task.text;
    list.append(li);
  }

  const left = tasks.filter((t) => !t.done).length;
  count.textContent = `${left} task${left === 1 ? "" : "s"} left`;
}

form.addEventListener("submit", (e) => {
  e.preventDefault();
  const text = input.value.trim();
  if (!text) return;
  tasks.push({ id: Date.now(), text, done: false });
  input.value = "";
  save();
  render();
});

list.addEventListener("click", (e) => {
  const li = e.target.closest("li");
  if (!li) return;
  const id = Number(li.dataset.id);

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

document.querySelectorAll("[data-filter]").forEach((btn) => {
  btn.addEventListener("click", () => {
    filter = btn.dataset.filter;
    document.querySelectorAll("[data-filter]").forEach((b) => b.classList.toggle("active", b === btn));
    render();
  });
});

render();
ResultEdit the code, then press Run or Ctrl+Enter

Bonus challenges

  • Double-click a task to edit its text.
  • Add a "Clear completed" button.
  • Add due dates with <input type="date"> and sort tasks by date with toSorted.
  • Drag to reorder tasks (look up the HTML Drag and Drop API).

Common mistakes and how to fix them

Tasks disappear after a refresh. You changed the array but forgot to call save(). Save after every change: add, tick and delete.

Clicking a new task does nothing. If you attached a click listener to each list item when the page loaded, tasks added later have no listener. Event delegation fixes this: one listener on the <ul> handles clicks for every item, now and in the future. The event delegation lesson walks through why.

Typing HTML into a task breaks the page. Never put user text into innerHTML. The solution sets the task text with textContent, which always treats it as plain text.

Empty tasks get added. Trim the input with .trim() and return early if the result is an empty string.

Check your work

  1. Add three tasks, tick one, refresh the page. All three should still be there and the ticked one should still be done.
  2. Switch between All, Active and Done. The counter should always count the tasks that are left, whichever filter is showing.
  3. Add a task called <b>bold</b>. It should appear as plain text, angle brackets and all.
  4. Use only the keyboard: type, press Enter, Tab to a checkbox and press Space. Everything should work without a mouse.

Questions beginners ask

Is this how React apps work? Very nearly. React also keeps state in variables and redraws the interface from it. The difference is that React works out which parts actually changed, so it can update large pages efficiently.

Why not store the HTML in localStorage? Saving the data rather than the markup keeps the stored information small and lets you change the design later without breaking saved tasks.

Where can I go from here? Add editing and due dates from the bonus list, then try the colour palette generator, which uses the same state and render pattern with keyboard shortcuts.