Module: JavaScript
JavaScript·077·7 MIN READ

077: DOM Project I: Todo Application Core

TOPICS COVERED: DOM Project I: Todo Application Core

Outcomes

By the end of this lesson, you can:

  • model Todo state with stable task objects;
  • implement add, complete, and delete state transitions;
  • write one deterministic render() function;
  • connect stable delegated events to dynamic controls;
  • load and save validated state with graceful failure; and
  • explain the complete state -> render -> event -> update -> save -> render cycle.

Retrieval Warm-Up

  1. Why must data parsed from localStorage be shape-validated?
  2. Which event best represents a checkbox state change?
  3. Why should a delete handler remove a task from state rather than only call li.remove()?

Terms

  • State: Minimal data describing the application at a moment in time. — Source: MDN: Glossary — MVC
  • Source of truth: Single authoritative store from which all views derive. — Source: MDN: Glossary — MVC
  • Data model: Shape of stored state: entities, fields, and relationships chosen deliberately. — Source: MDN: Glossary — MVC
  • State transition: Defined change from one valid state to the next in response to an event. — Source: MDN: Glossary — State machine
  • Deterministic render: Same application state always produces identical rendered output. — Source: WHATWG HTML: Rendering
  • Stable ID: Immutable identifier keeping rendered items matched to data across updates. — Source: MDN: crypto.randomUUID()
  • Event delegation: One listener handling many descendants through bubbling and closest(). — Source: MDN: Event bubbling
  • Persistence boundary: Layer serializing/deserializing state so core logic stays testable. — Source: MDN: Web Storage API
  • Source of truth (official): "The source of truth is the single authoritative data store for application state." — Source: MDN: Glossary — MVC
  • Deterministic render (official): "A deterministic render always produces the same output for the same state." — Source: WHATWG HTML: Rendering

Mental Model: One-Way Data Flow

The Todo app has one rule that resolves many bugs:

text
load -> state -> render
                 |
user event -> update state -> save -> render

State is the source of truth. The DOM is a projection of state, and storage is a fallible saved copy. Event handlers never manually patch one label while hoping everything else stays synchronized. They update state, attempt persistence, and render the complete view.

The model for one task is deliberately small:

js
{
  id: "a stable string",
  title: "visible user text",
  completed: false,
}

Do not use array position as identity. Positions change after deletion or sorting. Do not use titles as identity because two tasks can have the same title. crypto.randomUUID() supplies a practical browser-generated ID.

Self-Study Example: Complete Core App

Create three files. First, index.html:

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Todo app</title>
    <link rel="stylesheet" href="styles.css">
    <script src="app.js" defer></script>
  </head>
  <body>
    <main class="app">
      <h1>Todo app</h1>

      <form id="task-form">
        <label for="task-title">New task</label>
        <div class="add-row">
          <input
            id="task-title"
            name="title"
            required
            maxlength="80"
            autocomplete="off"
            aria-describedby="task-help">
          <button type="submit">Add</button>
        </div>
        <p id="task-help">Use 1 to 80 characters. Do not enter sensitive information.</p>
      </form>

      <p id="summary"></p>
      <ul id="task-list" class="task-list"></ul>
      <p id="status" role="status"></p>
    </main>
  </body>
</html>

Keep all three files in one folder and serve it over HTTP, for example with python -m http.server 8000, then open http://localhost:8000/. Verify startup with an empty storage key, add a task, reload, and confirm it remains; also test the invalid markup-looking title and the storage-failure message.

Add styles.css:

css
:root { font-family: system-ui, sans-serif; color: #17202a; background: #f4f6f7; }
body { margin: 0; }
.app { box-sizing: border-box; max-width: 42rem; min-height: 100vh; margin: auto; padding: 1.25rem; background: #fff; }
label { font-weight: 700; }
.add-row { display: flex; gap: 0.5rem; margin-block-start: 0.35rem; }
.add-row input { flex: 1; min-width: 0; }
input, button { font: inherit; padding: 0.6rem; }
button { cursor: pointer; }
button:focus-visible, input:focus-visible { outline: 3px solid #6c3483; outline-offset: 3px; }
.task-list { padding: 0; list-style: none; }
.task { display: grid; grid-template-columns: auto 1fr auto; align-items: start; gap: 0.65rem; padding-block: 0.75rem; border-block-end: 1px solid #abb2b9; }
.task.is-complete .task-title { color: #515a5a; text-decoration: line-through; text-decoration-thickness: 0.12em; }
@media (max-width: 28rem) { .task { grid-template-columns: auto 1fr; } .task button { grid-column: 2; justify-self: start; } }

Add app.js:

js
const STORAGE_KEY = "todo-course.state.v1";

const form = document.querySelector("#task-form");
const titleInput = document.querySelector("#task-title");
const taskList = document.querySelector("#task-list");
const summary = document.querySelector("#summary");
const status = document.querySelector("#status");

function isTask(value) {
  return value !== null
    && typeof value === "object"
    && typeof value.id === "string"
    && /^[A-Za-z0-9-]{1,100}$/.test(value.id)
    && typeof value.title === "string"
    && value.title.trim().length >= 1
    && value.title.length <= 80
    && typeof value.completed === "boolean";
}

function loadTasks() {
  try {
    const text = localStorage.getItem(STORAGE_KEY);
    if (text === null) return [];

    const payload = JSON.parse(text);
    if (payload?.version !== 1 || !Array.isArray(payload.tasks)) return [];
    if (!payload.tasks.every(isTask)) return [];
    const ids = new Set(payload.tasks.map((task) => task.id));
    return ids.size === payload.tasks.length ? payload.tasks : [];
  } catch (error) {
    console.warn("Saved tasks could not be loaded.", error);
    return [];
  }
}

const state = {
  tasks: loadTasks(),
};

function saveTasks() {
  try {
    const payload = { version: 1, tasks: state.tasks };
    localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
    return true;
  } catch (error) {
    console.warn("Tasks could not be saved.", error);
    return false;
  }
}

function createTaskItem(task) {
  const item = document.createElement("li");
  item.classList.add("task");
  item.classList.toggle("is-complete", task.completed);
  item.dataset.taskId = task.id;

  const checkbox = document.createElement("input");
  checkbox.type = "checkbox";
  checkbox.id = `complete-${task.id}`;
  checkbox.checked = task.completed;
  checkbox.dataset.action = "toggle";

  const label = document.createElement("label");
  label.classList.add("task-title");
  label.htmlFor = checkbox.id;
  label.textContent = task.title;

  const deleteButton = document.createElement("button");
  deleteButton.type = "button";
  deleteButton.dataset.action = "delete";
  deleteButton.textContent = `Delete ${task.title}`;

  item.append(checkbox, label, deleteButton);
  return item;
}

function render() {
  taskList.replaceChildren(...state.tasks.map(createTaskItem));

  const remaining = state.tasks.filter((task) => !task.completed).length;
  summary.textContent = `${remaining} ${remaining === 1 ? "task" : "tasks"} remaining, ${state.tasks.length} total.`;
}

function persistAndRender(successMessage) {
  const saved = saveTasks();
  status.textContent = saved
    ? successMessage
    : `${successMessage} The change could not be saved and may be lost after reload.`;
  render();
}

form.addEventListener("submit", (event) => {
  event.preventDefault();
  const title = titleInput.value.trim();

  if (title === "") {
    status.textContent = "Enter a task.";
    titleInput.focus();
    return;
  }

  state.tasks.push({
    id: crypto.randomUUID(),
    title,
    completed: false,
  });

  form.reset();
  persistAndRender(`Added task: ${title}.`);
  titleInput.focus();
});

taskList.addEventListener("change", (event) => {
  const checkbox = event.target;

  if (!(checkbox instanceof HTMLInputElement)
      || checkbox.dataset.action !== "toggle") {
    return;
  }

  const item = checkbox.closest("[data-task-id]");
  const task = state.tasks.find(
    (task) => task.id === item?.dataset.taskId,
  );

  if (!task) return;
  task.completed = checkbox.checked;
  persistAndRender(
    `${task.title} marked ${task.completed ? "complete" : "not complete"}.`,
  );
  const renderedItem = [...taskList.children].find(
    (item) => item.dataset.taskId === task.id,
  );
  renderedItem?.querySelector('input[data-action="toggle"]')?.focus();
});

taskList.addEventListener("click", (event) => {
  if (!(event.target instanceof Element)) return;

  const button = event.target.closest('button[data-action="delete"]');
  const item = button?.closest("[data-task-id]");

  if (!button || !item || !taskList.contains(item)) return;

  const task = state.tasks.find(
    (task) => task.id === item.dataset.taskId,
  );

  if (!task) return;
  state.tasks = state.tasks.filter((item) => item.id !== task.id);
  persistAndRender(`Deleted task: ${task.title}.`);
  titleInput.focus();
});

render();

Test with pointer and keyboard, then reload. Also enter <button>Surprise</button> as a title. It must appear as text. Complete and delete tasks, inspect the saved payload, and test at 200% zoom or a narrow viewport.

Trace Each State Transition

Add: the submit event is canceled because this local app replaces navigation. The handler normalizes the title, pushes a model object, resets the form, saves, renders, reports status, and returns focus for rapid entry.

Complete: the delegated change listener verifies an actual checkbox/action, resolves its task ID against current state, copies checked into the model, saves, and renders.

Delete: the delegated click listener locates only a known delete action, resolves a current task, creates a new filtered array, saves, renders, and moves focus because the initiating button no longer exists.

The helper persistAndRender() does not make storage authoritative. State has already changed. It reports honestly if persistence fails and still renders the in-memory result.

Why Full Rendering Works Here

For a small list, replacing all children is simple and reliable. It guarantees that:

  • checkbox checkedness matches state;
  • completion classes match state;
  • labels and button names match titles;
  • deleted tasks cannot linger; and
  • the summary uses the same state.

It also means DOM node identity changes. If a render occurs while focus is inside a task, that node is removed. Our handlers deliberately focus the input after deletion. Checkbox activation normally retains a meaningful flow, but production apps should test focus carefully and may patch a node or restore focus by task ID where needed. Correctness comes before a premature keyed-rendering system.

Intermediate Example: Immutable Toggle Transition

Instead of mutating one task object, return a new array/object:

js
function toggleTask(tasks, id, completed) {
  return tasks.map((task) => (
    task.id === id ? { ...task, completed } : task
  ));
}

state.tasks = toggleTask(state.tasks, task.id, checkbox.checked);

This pure transition is straightforward to test:

js
const before = [{ id: "1", title: "Test", completed: false }];
const after = toggleTask(before, "1", true);
console.assert(before[0].completed === false);
console.assert(after[0].completed === true);

Mutation is not inherently wrong in this small app. The important rule is that the model changes before render. Immutable transitions become useful as state complexity and testing grow.

Optional Advanced Example: Cross-Tab Refresh

js
window.addEventListener("storage", (event) => {
  if (event.key !== STORAGE_KEY || event.storageArea !== localStorage) return;
  state.tasks = loadTasks();
  status.textContent = "Tasks were updated in another tab.";
  render();
});

This updates another open tab. It does not prevent simultaneous writes from overwriting each other; Web Storage has no transaction/locking model authors should depend on. A shared production Todo app belongs on a server with conflict handling.

Mistakes and Debugging

  • Using the DOM as state: counting checked DOM inputs can disagree with stored model data. Derive everything from state.tasks.
  • Index IDs: deleting the first item changes positions. Use stable IDs.
  • Listener registration in render(): register delegated listeners once on stable containers.
  • One click listener for checkbox meaning: use semantic change and read checked.
  • Saving before state changes: persistence then contains stale data.
  • Rendering before state changes: output remains stale until another render.
  • Assuming save succeeds: report failures and preserve an in-memory experience.
  • Using title in innerHTML: stored and form data are untrusted. Use textContent.
  • Duplicate generated input IDs: labels become unreliable. Prefix stable IDs.

Debug in layers: log the event/action, log the resolved ID, inspect state after the transition, inspect the serialized payload, then inspect rendered DOM. If state is correct, the bug is render/persistence. If state is wrong, fix event/update logic first.

Accessibility, Security, and Performance

Accessibility: semantic form, list, checkboxes, labels, and buttons provide names and keyboard operation. The status region announces concise outcomes. The summary is visible text. Focus is intentionally restored after add/delete. Completion uses a native checked state and a line-through rather than color alone. Test keyboard order, screen reader announcements, touch target size, zoom/reflow, and visible focus.

Security/privacy: task text from forms and storage is untrusted. textContent prevents it from becoming markup. Local storage must not hold secrets or sensitive notes and can be read by same-origin scripts. Client validation and action checks do not replace server authorization in a networked app.

Performance: full rendering is appropriate for small Todo lists. It performs one list replacement per logical event. Web Storage writes are synchronous, so save only after meaningful changes. For hundreds or thousands of items, measure and consider pagination, incremental keyed updates, or IndexedDB, but do not complicate a small app preemptively.

Exercises

Core

Add three seed tasks when storage is empty, without saving them until the first user change.

Practice

Add a createdAt ISO string to new tasks, validate it on load, and display a safe <time> element.

Professional Extension

Replace mutable checkbox updating with the pure toggleTask() transition and add console assertions for a missing ID.

Core

js
// Replace the original state initialization; do not declare a second `state`.
state.tasks = state.tasks.length > 0 ? state.tasks : [
    { id: crypto.randomUUID(), title: "Add a task", completed: false },
    { id: crypto.randomUUID(), title: "Complete a task", completed: false },
    { id: crypto.randomUUID(), title: "Delete a task", completed: false },
  ];

No saveTasks() call occurs during startup, so seed data is not persisted until an action.

Practice

Create with createdAt: new Date().toISOString(). Add a validator for exactly the UTC millisecond format produced by toISOString(), then extend isTask():

js
function isIsoTimestamp(value) {
  if (typeof value !== "string"
      || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) {
    return false;
  }

  const date = new Date(value);
  return !Number.isNaN(date.valueOf()) && date.toISOString() === value;
}

// Add to the isTask() return expression:
&& isIsoTimestamp(value.createdAt);

The regular expression rejects alternate date syntaxes and missing components. The validity check prevents an invalid date from reaching toISOString(), and the exact round trip rejects normalized values such as an impossible calendar date that JavaScript rolls into another day.

Render:

js
const created = document.createElement("time");
created.dateTime = task.createdAt;
created.textContent = new Date(task.createdAt).toLocaleDateString();
item.append(checkbox, label, " Created ", created, deleteButton);

Professional Extension

js
function toggleTask(tasks, id, completed) {
  return tasks.map((task) => task.id === id ? { ...task, completed } : task);
}

const sample = [{ id: "1", title: "A", completed: false }];
console.assert(toggleTask(sample, "1", true)[0].completed);
console.assert(toggleTask(sample, "missing", true)[0] === sample[0]);

In the change handler: state.tasks = toggleTask(state.tasks, task.id, checkbox.checked);.

Recap

  • State is the source of truth; DOM and storage are outputs/boundaries.
  • Stable IDs identify tasks across deletion and rendering.
  • Handlers validate an event, update state, save, and render once.
  • render() deterministically derives controls, classes, labels, and summaries.
  • Delegation supports dynamic list controls without listeners in render.
  • Safe text, honest storage failure, semantics, and focus are core behavior.

Official References