FullStack Course LogoFullStack Course
Module: JavaScript
JavaScript·077·10 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

Before building the app, answer these from memory:

  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()?

If you are unsure about an answer, keep the question in mind while you work through the example. Each one points to a design decision in the implementation.

Terms

The following terms are used throughout the lesson. The definitions are deliberately precise because these words describe boundaries in the application, not just general ideas:

  • 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

Many Todo bugs come from trying to keep several representations synchronized by hand. Use one rule instead: state is authoritative, and every visible result is derived from it.

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

The DOM is a projection of state. Storage is a fallible saved copy that can be missing, malformed, or temporarily unavailable. That distinction matters: storage is useful for restoring work, but it is not more authoritative than the state currently in memory.

An event handler therefore should not manually change one label and assume the rest of the interface is still synchronized. It should update state, attempt persistence, and render the complete view. This gives you a reliable cycle to follow when debugging: inspect the event, inspect the state transition, inspect the save attempt, and then inspect the rendered DOM.

The model for one task is intentionally small:

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

Do not use an array position as identity. Deleting or sorting items changes positions. Do not use a title as identity either, because two tasks may legitimately have the same title. crypto.randomUUID() gives the browser a practical way to generate an ID that remains attached to the task as the array changes.

Self-Study Example: Complete Core App

Create three files in one folder. Start with 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 the three files together and serve the folder over HTTP. For example, run python -m http.server 8000 and open http://localhost:8000/. Starting from an empty storage key, verify that the app loads, add a task, reload, and confirm that the task remains. Also try a title that looks like markup and trigger the storage-failure message so that you have checked the behavior rather than only the happy path.

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; } }

Now add app.js. The validator and persistence functions establish the boundary between untrusted saved data and the in-memory model before the rendering code uses that model.

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();

Exercise the app with both a pointer and the keyboard, then reload it. Enter <button>Surprise</button> as a title; it must be displayed as text rather than interpreted as an element. Complete and delete tasks, inspect the saved payload, and check the layout at 200% zoom or in a narrow viewport.

Trace Each State Transition

The code is easier to reason about when each user action is described as a complete transition rather than as a collection of DOM operations.

Add: The submit event is canceled because this local app handles the action instead of navigating to a new document. The handler trims the title, creates and pushes a model object, resets the form, saves the new state, renders the view, reports the result, and returns focus to the input for rapid entry.

Complete: The delegated change listener first verifies that the event came from an actual checkbox representing the toggle action. It resolves the checkbox's task ID against the current state, copies checked into the model, saves, and renders. Resolving the ID against current state prevents a stale or unrelated element from changing an arbitrary task.

Delete: The delegated click listener accepts only a known delete action, finds its task container, resolves a current task, creates a new filtered array without that task, saves, renders, and moves focus because the button that initiated the action no longer exists.

The helper persistAndRender() does not turn storage into the authority. The in-memory state has already changed before the helper runs. The helper reports whether the persistence attempt succeeded, warns the user when the change may be lost after a reload, and renders the in-memory result either way.

Why Full Rendering Works Here

For a small Todo list, replacing all children is a reasonable trade-off. It keeps the rendering rule simple and makes stale DOM fragments difficult to leave behind. Each render 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.

There is a cost: DOM node identity changes. If rendering occurs while focus is inside a task, that task node is removed and recreated. The handlers deliberately focus the input after deletion. Checkbox activation normally retains a meaningful flow, but a production app should test focus behavior carefully and may need to patch a node or restore focus by task ID.

That is a reason to understand the trade-off, not a reason to introduce keyed rendering immediately. For this app, correctness and a single dependable rendering path are more valuable than premature optimization.

Intermediate Example: Immutable Toggle Transition

The main example mutates the matching task object. Another valid approach is to make the transition pure: return a new array and a new object for the task that changed.

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);

The advantage is easiest to see in a test. The original input remains unchanged, while the returned value contains the transition's result:

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 automatically a defect in a small application. The essential ordering rule is that the model changes before render() runs. Immutable transitions become increasingly useful as state becomes more complex, as more code needs to test transitions, or as you want to make accidental shared references easier to detect.

Optional Advanced Example: Cross-Tab Refresh

The storage event can refresh an already-open tab when another same-origin tab changes the saved value:

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 keeps another open tab current, but it is not conflict resolution. Two tabs can read the same old state and then write different updates; one write can overwrite the other. Web Storage does not provide a transaction or locking model that application authors should rely on. A shared production Todo app needs a server-side data model and an explicit strategy for handling conflicts.

Mistakes and Debugging

  • Using the DOM as state: Counting checked DOM inputs can disagree with the stored model. Derive every visible result from state.tasks.
  • Index IDs: Deleting the first item changes positions and can make an index point at a different task. Use stable IDs.
  • Listener registration in render(): Rendering repeatedly would register repeated handlers. Register delegated listeners once on stable containers.
  • One click listener for checkbox meaning: A click does not express the final checkbox state as clearly as a semantic change event. Use change and read checked.
  • Saving before state changes: The persistence layer would receive stale data.
  • Rendering before state changes: The old model would be rendered again, so the output would remain stale until a later render.
  • Assuming save succeeds: Storage can fail. Report the failure and keep the in-memory experience usable.
  • Using title in innerHTML: Form and storage data are untrusted. Use textContent so a title remains text.
  • Duplicate generated input IDs: Labels become unreliable when they do not identify one control uniquely. Build them from stable IDs.

Debug the problem in layers. First log the event and the action the handler recognized. Then log the resolved task ID, inspect state immediately after the transition, inspect the serialized payload, and finally inspect the rendered DOM. If state is correct, focus on rendering or persistence. If state is wrong, fix event targeting or update logic before changing the DOM code.

Accessibility, Security, and Performance

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

Security/privacy: Task text from both the form and storage is untrusted. textContent prevents it from becoming markup. Do not put secrets or sensitive notes in local storage: same-origin scripts can read it. Client-side validation and action checks help protect this interface, but they are not a substitute for server authorization in a networked application.

Performance: Full rendering is appropriate for a small Todo list and performs one list replacement per logical event. Web Storage writes are synchronous, so save after meaningful changes rather than on every incidental operation. With hundreds or thousands of items, measure the actual cost and consider pagination, incremental keyed updates, or IndexedDB. Do not add that complexity to a small app before its behavior requires it.

Exercises

Core

When storage is empty, add three seed tasks without saving them until the user makes the first 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 },
  ];

There is no saveTasks() call during startup. As a result, the seed data exists in memory for the initial render but is not persisted until an action causes a save.

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; the DOM and storage are outputs or 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 adding listeners during rendering.
  • Safe text handling, honest storage-failure reporting, semantic markup, and focus management are core behavior.

Official References

Reader page: /javascript/lesson/077/dom-project-i-todo-application-core