IntermediateJavaScript · Lesson 10 of 10

Project: A Complete To-Do App

Combine state, rendering, delegation, filters and persistence into one well-structured app.

This project brings the whole level together. All data lives in one state object. Every change goes through a small function that updates the state, saves it, and calls render() — the page is always drawn from the data, never edited piecemeal.

Events are delegated to the list, so new items need no extra listeners. Filters (All / Active / Done) are just a value in state that render() respects.

Text typed by users is inserted with textContent, never innerHTML, so a task like <img src=x onerror=alert(1)> is shown as plain text instead of running as code.

index.htmlHTML
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Tasks</title>
    <style>
      .done span { text-decoration: line-through; opacity: 0.6; }
      .filters button[aria-pressed="true"] { font-weight: bold; }
    </style>
    <script src="app.js" defer></script>
  </head>
  <body>
    <h1>Tasks</h1>
    <form id="new-task">
      <input name="title" placeholder="What needs doing?" required maxlength="120" />
      <button>Add</button>
    </form>
    <div class="filters">
      <button data-filter="all">All</button>
      <button data-filter="active">Active</button>
      <button data-filter="done">Done</button>
    </div>
    <ul id="tasks"></ul>
    <p id="remaining"></p>
  </body>
</html>
Live preview in a sandboxed frame
app.jsJavaScript
const STORAGE_KEY = "dolese.tasks.v1";

const state = {
  tasks: load(),
  filter: "all",
};

function load() {
  try {
    return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
  } catch {
    return [];
  }
}

function update(change) {
  change(state);
  localStorage.setItem(STORAGE_KEY, JSON.stringify(state.tasks));
  render();
}

const list = document.querySelector("#tasks");
const form = document.querySelector("#new-task");

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

  list.replaceChildren(
    ...visible.map((task) => {
      const li = document.createElement("li");
      li.dataset.id = task.id;
      li.classList.toggle("done", task.done);

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

      const title = document.createElement("span");
      title.textContent = task.title;       // safe: never innerHTML for user text

      const remove = document.createElement("button");
      remove.textContent = "Delete";
      remove.className = "delete";

      li.append(checkbox, " ", title, " ", remove);
      return li;
    }),
  );

  const left = state.tasks.filter((t) => !t.done).length;
  document.querySelector("#remaining").textContent = `${left} task${left === 1 ? "" : "s"} left`;
  for (const btn of document.querySelectorAll("[data-filter]")) {
    btn.setAttribute("aria-pressed", String(btn.dataset.filter === state.filter));
  }
}

form.addEventListener("submit", (event) => {
  event.preventDefault();
  const title = form.elements.title.value.trim();
  if (!title) return;
  update((s) => s.tasks.push({ id: crypto.randomUUID(), title, done: false }));
  form.reset();
});

list.addEventListener("click", (event) => {
  const id = event.target.closest("li")?.dataset.id;
  if (!id) return;
  if (event.target.matches(".toggle")) {
    update((s) => {
      const task = s.tasks.find((t) => t.id === id);
      task.done = !task.done;
    });
  } else if (event.target.matches(".delete")) {
    update((s) => { s.tasks = s.tasks.filter((t) => t.id !== id); });
  }
});

document.querySelector(".filters").addEventListener("click", (event) => {
  const filter = event.target.dataset.filter;
  if (filter) update((s) => { s.filter = filter; });
});

render();

Key points

  • Single source of truth: state → render(); never patch the page by hand.
  • Route every change through one update() that saves and re-renders.
  • Insert user text with textContent to prevent script injection (XSS).

Exercise

Add: double-click a task to edit its title, a "Clear completed" button, a due date per task with overdue tasks highlighted, and keyboard support (Enter to save an edit, Escape to cancel).

Show solution

Try the exercise yourself first — then compare your approach with this one.

Every feature follows the same pattern: change state in update(), then render(). Editing stores the id being edited in state; the row then renders an input instead of the title. Enter saves, Escape cancels, and the input saves on blur too. Overdue tasks get a class when their due date is before today and they aren't done.

index.htmlHTML
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Tasks</title>
    <style>
      .done .title { text-decoration: line-through; opacity: 0.6; }
      .overdue .due { color: #b00020; font-weight: bold; }
    </style>
    <script src="app.js" defer></script>
  </head>
  <body>
    <form id="new-task">
      <input name="title" placeholder="What needs doing?" required maxlength="120" />
      <input name="due" type="date" />
      <button>Add</button>
    </form>
    <ul id="tasks"></ul>
    <button id="clear-done">Clear completed</button>
    <p id="remaining"></p>
  </body>
</html>
Live preview in a sandboxed frame
app.jsJavaScript
const KEY = "dolese.tasks.v2";
const state = { tasks: load(), editing: null };

function load() {
  try {
    return JSON.parse(localStorage.getItem(KEY)) ?? [];
  } catch {
    return [];
  }
}

function update(change) {
  change(state);
  localStorage.setItem(KEY, JSON.stringify(state.tasks));
  render();
}

const list = document.querySelector("#tasks");
const form = document.querySelector("#new-task");
const today = new Date().toISOString().slice(0, 10);

function render() {
  list.replaceChildren(
    ...state.tasks.map((task) => {
      const li = document.createElement("li");
      li.dataset.id = task.id;
      li.classList.toggle("done", task.done);
      li.classList.toggle("overdue", Boolean(task.due) && task.due < today && !task.done);

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

      let title;
      if (state.editing === task.id) {
        title = document.createElement("input");
        title.className = "edit";
        title.value = task.title;
      } else {
        title = document.createElement("span");
        title.className = "title";
        title.textContent = task.title;
      }

      const due = document.createElement("span");
      due.className = "due";
      due.textContent = task.due ? ` (due ${task.due})` : "";

      const remove = document.createElement("button");
      remove.textContent = "Delete";
      remove.className = "delete";

      li.append(toggle, " ", title, due, " ", remove);
      return li;
    }),
  );
  list.querySelector(".edit")?.focus();
  const left = state.tasks.filter((t) => !t.done).length;
  document.querySelector("#remaining").textContent = `${left} task${left === 1 ? "" : "s"} left`;
}

function finishEdit(input, save) {
  const id = input.closest("li").dataset.id;
  update((s) => {
    const title = input.value.trim();
    if (save && title) s.tasks.find((t) => t.id === id).title = title;
    s.editing = null;
  });
}

form.addEventListener("submit", (event) => {
  event.preventDefault();
  const title = form.elements.title.value.trim();
  if (!title) return;
  update((s) => s.tasks.push({ id: crypto.randomUUID(), title, due: form.elements.due.value, done: false }));
  form.reset();
});

list.addEventListener("click", (event) => {
  const id = event.target.closest("li")?.dataset.id;
  if (event.target.matches(".toggle")) update((s) => { const t = s.tasks.find((x) => x.id === id); t.done = !t.done; });
  if (event.target.matches(".delete")) update((s) => { s.tasks = s.tasks.filter((t) => t.id !== id); });
});

list.addEventListener("dblclick", (event) => {
  if (event.target.matches(".title")) update((s) => { s.editing = event.target.closest("li").dataset.id; });
});

list.addEventListener("keydown", (event) => {
  if (!event.target.matches(".edit")) return;
  if (event.key === "Enter") finishEdit(event.target, true);
  if (event.key === "Escape") finishEdit(event.target, false);
});

list.addEventListener("focusout", (event) => {
  if (event.target.matches(".edit") && state.editing) finishEdit(event.target, true);
});

document.querySelector("#clear-done").addEventListener("click", () => {
  update((s) => { s.tasks = s.tasks.filter((t) => !t.done); });
});

render();

Check your understanding

  1. What does "single source of truth" mean in this app?

  2. Why does every change go through one update() function?

  3. A user adds the task <img src=x onerror=alert(1)>. What does the app show?

  4. Why do new tasks need no extra event listeners?

Ask AI