Module: JavaScript
JavaScript·078·9 MIN READ

078: DOM Project II: Refactor, Accessibility, and Performance

TOPICS COVERED: DOM Project II: Refactor, Accessibility, and Performance

Outcomes

By the end of this lesson, you can:

  • derive active, completed, and all filtered views from one task array;
  • render meaningful empty states and counts;
  • refactor Todo code around state, rendering, events, and persistence;
  • manage focus after destructive actions;
  • test accessibility, security, storage failure, and responsive behavior; and
  • diagnose bugs by following one-way data flow.

Retrieval Warm-Up

  1. Recite the Todo app's event-to-render cycle.
  2. Why is a filter a view of tasks rather than a second task array?
  3. What should happen if localStorage.setItem() throws?

Terms

  • Filter state: The current view choice, such as "all", "active", or "completed" (course term).
  • Derived data: Data calculated from source state, not independently stored (course term).
  • Empty state: Content explaining why a region has no items and what the user can do (course term).
  • Refactor: Improve internal structure without intentionally changing behavior (course term).
  • Regression: Existing behavior that breaks after a change (course term).
  • Focus management: Deliberately moving/restoring keyboard focus after DOM changes. — Source: MDN: HTMLElement.focus()
  • Accessibility tree: Assistive-tech-facing structure derived from the DOM exposing names, roles, states. — Source: MDN: Glossary — Accessibility tree
  • Audit: A systematic check against explicit requirements (course term).
  • Accessibility tree (official): "The accessibility tree is a structure of accessible objects derived from the DOM, exposed to assistive technology." — Source: MDN: Accessibility tree
  • Focus management (official): "Focus management ensures keyboard focus moves predictably, often restored after DOM changes." — Source: MDN: Focus management

Mental Model: State Is Small; Views Are Derived

The final state needs one task array and one filter:

js
const state = {
  tasks: [],
  filter: "all",
};

Do not maintain allTasks, activeTasks, and completedTasks arrays. They drift when one update forgets one array. Instead:

text
state.tasks + state.filter -> getVisibleTasks() -> render()

The finished architecture is still:

text
load -> state -> render
event -> validate intent -> update state -> save if persistent -> render -> manage focus/status

Filtering changes only view state, so it does not need a storage write. Add, toggle, delete, and clear-completed change task state, so they attempt a write.

Self-Study Example: Finished Accessible Todo

Begin with 077. Replace the content inside <main class="app"> with the following markup:

html
<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>

<fieldset id="filters">
  <legend>Show tasks</legend>
  <label><input type="radio" name="filter" value="all" checked> All</label>
  <label><input type="radio" name="filter" value="active"> Active</label>
  <label><input type="radio" name="filter" value="completed"> Completed</label>
</fieldset>

<p id="summary"></p>
<p id="empty-state" hidden></p>
<ul id="task-list" class="task-list"></ul>
<button id="clear-completed" type="button">Clear completed</button>
<p id="status" role="status"></p>

Keep 066's base CSS and append:

css
fieldset { margin-block: 1.5rem; border: 1px solid #626567; }
fieldset label { display: inline-flex; align-items: center; gap: 0.25rem; margin-inline-end: 1rem; font-weight: 400; }
#empty-state { padding: 1rem; background: #eaf2f8; }
#clear-completed { margin-block-start: 1rem; }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }

Use the following final app.js:

js
const STORAGE_KEY = "todo-course.state.v1";
const FILTERS = new Set(["all", "active", "completed"]);

const form = document.querySelector("#task-form");
const titleInput = document.querySelector("#task-title");
const filters = document.querySelector("#filters");
const taskList = document.querySelector("#task-list");
const summary = document.querySelector("#summary");
const emptyState = document.querySelector("#empty-state");
const clearCompletedButton = document.querySelector("#clear-completed");
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(),
  filter: "all",
};

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

function getVisibleTasks() {
  if (state.filter === "active") {
    return state.tasks.filter((task) => !task.completed);
  }
  if (state.filter === "completed") {
    return state.tasks.filter((task) => task.completed);
  }
  return state.tasks;
}

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() {
  const visibleTasks = getVisibleTasks();
  taskList.replaceChildren(...visibleTasks.map(createTaskItem));

  const activeCount = state.tasks.filter((task) => !task.completed).length;
  const completedCount = state.tasks.length - activeCount;
  summary.textContent = `${activeCount} active, ${completedCount} completed, ${state.tasks.length} total.`;

  emptyState.hidden = visibleTasks.length !== 0;
  taskList.hidden = visibleTasks.length === 0;

  if (state.tasks.length === 0) {
    emptyState.textContent = "No tasks yet. Add a task above.";
  } else if (state.filter === "active") {
    emptyState.textContent = "No active tasks.";
  } else if (state.filter === "completed") {
    emptyState.textContent = "No completed tasks.";
  }

  clearCompletedButton.disabled = completedCount === 0;
}

function commit(message) {
  const saved = saveTasks();
  status.textContent = saved
    ? message
    : `${message} 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();
  commit(`Added task: ${title}.`);
  titleInput.focus();
});

filters.addEventListener("change", (event) => {
  const radio = event.target;
  if (!(radio instanceof HTMLInputElement) || radio.name !== "filter") return;
  if (!FILTERS.has(radio.value)) return;

  state.filter = radio.value;
  render();
  status.textContent = `Showing ${state.filter} tasks.`;
});

taskList.addEventListener("change", (event) => {
  const checkbox = event.target;
  if (!(checkbox instanceof HTMLInputElement)
      || checkbox.dataset.action !== "toggle") return;

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

  task.completed = checkbox.checked;
  commit(`${task.title} marked ${task.completed ? "complete" : "not complete"}.`);

  const renderedItem = [...taskList.children].find(
    (item) => item.dataset.taskId === task.id,
  );
  const renderedCheckbox = renderedItem?.querySelector('input[data-action="toggle"]');
  const filterControl = document.querySelector(
    `input[name="filter"][value="${state.filter}"]`,
  );
  (renderedCheckbox ?? filterControl)?.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 visibleBefore = getVisibleTasks();
  const deletedIndex = visibleBefore.findIndex(
    (task) => task.id === item.dataset.taskId,
  );
  const task = visibleBefore[deletedIndex];
  if (!task) return;

  state.tasks = state.tasks.filter((item) => item.id !== task.id);
  commit(`Deleted task: ${task.title}.`);

  const remainingButtons = taskList.querySelectorAll('button[data-action="delete"]');
  const nextButton = remainingButtons[Math.min(deletedIndex, remainingButtons.length - 1)];
  (nextButton ?? titleInput).focus();
});

clearCompletedButton.addEventListener("click", () => {
  const completedCount = state.tasks.filter((task) => task.completed).length;
  if (completedCount === 0) return;

  state.tasks = state.tasks.filter((task) => !task.completed);
  commit(`Cleared ${completedCount} completed ${completedCount === 1 ? "task" : "tasks"}.`);
  titleInput.focus();
});

render();

Use the app entirely by keyboard. Add duplicate titles, markup-looking text, and 80-character text. Exercise each filter when it is empty. Complete an active task and observe that it disappears from the Active view; focus moves to the current filter control instead of vanishing silently. Delete the middle task and verify focus moves to a nearby Delete button. Clear completed tasks and verify focus moves to the title input rather than the Clear completed button, which render() has just disabled because no completed tasks remain.

Filtering and Empty States

getVisibleTasks() is a derived selector function. It never changes state. The source array remains complete, so switching filters cannot lose tasks.

Empty states distinguish three situations:

  • No source tasks: explain how to add one.
  • Active view empty: all existing tasks may be complete.
  • Completed view empty: no task has been completed.

The empty message is ordinary visible text, not merely a placeholder inside an invalid list. The ul contains only li elements when shown. The browser's built-in hidden presentation removes the inactive region from visual and accessibility presentation. Avoid a blanket [hidden] { display: none; } override: it also defeats the distinct hidden="until-found" state, which browsers can reveal when find-in-page or fragment navigation reaches the content.

Refactoring Boundaries

The final code has four clear areas:

  • Persistence: loadTasks(), saveTasks(), schema checks.
  • State/derivation: state, getVisibleTasks().
  • Rendering: createTaskItem(), render().
  • Events/transitions: submit, filter, change, click, clear handlers.

commit() centralizes a repeated sequence but remains small: save, report, render. Filtering calls render() directly because it does not persist task data. This is a useful refactor, not abstraction for its own sake.

Avoid splitting every three lines into a helper. Extract logic when it has a coherent name, is reused, or benefits from isolated tests.

Intermediate Example: Pure Filter Tests

Make derivation independently testable:

js
function filterTasks(tasks, filter) {
  if (filter === "active") return tasks.filter((task) => !task.completed);
  if (filter === "completed") return tasks.filter((task) => task.completed);
  return tasks;
}

const sample = [
  { id: "1", title: "Open", completed: false },
  { id: "2", title: "Done", completed: true },
];

console.assert(filterTasks(sample, "all").length === 2);
console.assert(filterTasks(sample, "active")[0].id === "1");
console.assert(filterTasks(sample, "completed")[0].id === "2");

Then getVisibleTasks() can return filterTasks(state.tasks, state.filter). Small pure functions make regression checks cheap.

Optional Advanced Example: Confirm Bulk Destruction

Clearing completed tasks is destructive. A confirmation can be appropriate:

js
if (!window.confirm(`Delete ${completedCount} completed tasks?`)) {
  return;
}

A stronger product design offers Undo. Do not create a custom modal until its labeling, focus behavior, Escape behavior, and focus return are correct.

Mistakes and Debugging

  • Filtering by deleting: filtering is derived display, not a destructive state update.
  • Persisting filtered arrays: save complete task state, not just the current view.
  • No filter allowlist: validate UI/storage values against known options.
  • Putting plain text directly in ul: render an external empty-state paragraph or a valid li.
  • Losing focus after render: when a focused node disappears, deliberately choose the next logical control.
  • Announcing too much: status should report outcomes, not duplicate every visible DOM change.
  • Disabling with CSS only: use the actual disabled property so semantics and interaction agree.
  • Hiding controls only visually: use hidden when content should leave all presentations.
  • Refactoring and changing behavior simultaneously: preserve a manual test checklist and change one boundary at a time.

Follow the architecture to debug: inspect state.tasks and state.filter; call getVisibleTasks(); run render() manually; inspect the DOM/accessibility tree; then inspect persistence. A stale view means render/derivation. A task returning after reload means save/load. A double action means listener registration or propagation.

Accessibility Audit

Test rather than assuming:

  • Navigate every control with Tab/Shift+Tab and activate with keyboard.
  • Confirm visible focus is not obscured and order follows DOM order.
  • Confirm every input has a label and every button has a specific name.
  • At 200% and 400% zoom, check reflow without horizontal two-dimensional scrolling.
  • Check text contrast, control/focus contrast, and more than color for state.
  • Inspect the accessibility tree for list, checkbox names/states, group/legend, status, and disabled Clear button.
  • Confirm status updates announce without stealing focus.
  • Confirm completing/deleting a filtered task leaves focus in a logical place.
  • Test touch-sized targets and narrow mobile layout.

ARIA supplements native HTML here; it does not replace it. The filter is a native radio group inside fieldset/legend, which communicates a single-choice relationship without a custom tab widget.

Security, Privacy, and Performance Audit

Security/privacy: no task value enters innerHTML; textContent is used. Loaded payloads are parsed in try/catch and schema-validated. Action and filter values use allowlists/current-state lookups. No secrets belong in storage. A server-backed version must authenticate, authorize each task operation, validate again, encode output safely, and handle conflicts.

Performance: handlers cause at most one meaningful render and persistent transition. Filter changes do not write storage. Synchronous storage payloads stay small. Full list replacement is fine for a personal list; profile before changing it. Avoid layout reads/writes in loops. Larger data calls for pagination/virtualization and asynchronous IndexedDB or server storage.

Exercises

Run the completed 066 app through a local HTTP server. Verify each change with keyboard input, reload persistence, 200%/400% zoom, and a narrow viewport; do not treat a visually correct list as proof that state and storage agree.

Core

Add an All done message when there are tasks but activeCount === 0, without replacing filter-specific empty messages.

Practice

Add an Edit action that replaces a task title after validating 1-80 characters. For this exercise use prompt(), then update state, save, and render safely.

Professional Extension

Implement one-level Undo for Clear completed using an Undo button that is normally hidden. Restore removed tasks, save, render, announce, and return focus.

Core

Add <p id="completion-message" role="status"></p> near the summary, select it, then in render():

js
completionMessage.textContent = state.tasks.length > 0 && activeCount === 0
  ? "All tasks are complete."
  : "";

Practice

Render an Edit button:

js
const editButton = document.createElement("button");
editButton.type = "button";
editButton.dataset.action = "edit";
editButton.textContent = `Edit ${task.title}`;
item.append(checkbox, label, editButton, deleteButton);

In the delegated click handler before delete handling:

js
const actionButton = event.target.closest("button[data-action]");
if (actionButton?.dataset.action === "edit") {
  const id = actionButton.closest("[data-task-id]")?.dataset.taskId;
  const task = state.tasks.find((item) => item.id === id);
  if (!task) return;
  const result = window.prompt("Edit task", task.title);
  if (result === null) return;
  const title = result.trim();
  if (title.length < 1 || title.length > 80) {
    status.textContent = "Task must contain 1 to 80 characters.";
    return;
  }
  task.title = title;
  commit(`Updated task: ${title}.`);
  return;
}

prompt() is only a compact exercise tool; a labeled inline edit form is better product UI.

Professional Extension

html
<button id="undo" type="button" hidden>Undo clear completed</button>
js
const undoButton = document.querySelector("#undo");
let lastCleared = [];

// In clear handler before filtering:
lastCleared = state.tasks.filter((task) => task.completed);
state.tasks = state.tasks.filter((task) => !task.completed);
undoButton.hidden = false;

undoButton.addEventListener("click", () => {
  if (lastCleared.length === 0) return;
  state.tasks = [...state.tasks, ...lastCleared];
  const count = lastCleared.length;
  lastCleared = [];
  undoButton.hidden = true;
  commit(`Restored ${count} completed ${count === 1 ? "task" : "tasks"}.`);
  titleInput.focus();
});

Recap

  • Keep one task array and derive filtered views.
  • Render explicit source-empty and filter-empty states.
  • Separate persistence, state/derivation, rendering, and event transitions.
  • Save only persistent state changes; render view-only filter changes directly.
  • Manage focus when rendering removes the active control.
  • Audit keyboard, semantics, status, contrast, zoom, storage failure, untrusted text, and performance.
  • The final architecture remains state -> render -> event -> update -> save -> render.

Official References

Rendering, layout, and frame scheduling

The browser parses HTML/CSS, builds style and layout information, paints pixels, and may composite layers. A DOM or style change can invalidate style or layout. Reflow (layout) calculates geometry; repaint draws changed pixels; compositing combines prepared layers, often without recalculating layout. The exact pipeline is browser-dependent, so use these as useful categories rather than promises about internal implementation.

js
const box = document.querySelector("#box");
let frame;

function moveBox(x) {
  cancelAnimationFrame(frame);
  frame = requestAnimationFrame(() => {
    box.style.transform = `translateX(${x}px)`;
  });
}

moveBox(40);

requestAnimationFrame schedules visual work before the next repaint and supplies a timestamp. It is preferable to a fast interval for animation, but it does not make expensive layout work free. Avoid layout thrashing: repeatedly writing a style and then reading geometry can force the browser to flush pending layout. Batch reads, then writes, and measure with DevTools rather than assuming every property has the same cost.

js
const width = box.getBoundingClientRect().width; // read
box.style.width = `${width + 10}px`;             // write

Test at 60 Hz and with reduced motion. Confirm that canceling a pending frame prevents obsolete work. Use PerformanceObserver or the Performance panel for long tasks and layout shifts; do not infer smoothness from a single machine.

Web Workers and postMessage

A dedicated Web Worker runs JavaScript in a separate agent, so CPU-heavy work can avoid blocking the page's main event loop. It cannot access the DOM directly. Messages use structured cloning by default; transferables such as ArrayBuffer can move ownership instead of copying.

js
// main.js
const worker = new Worker("worker.js", { type: "module" });
worker.addEventListener("message", (event) => {
  console.assert(event.data === 499999500000);
  worker.terminate();
});
worker.postMessage({ limit: 1_000_000 });
js
// worker.js
self.addEventListener("message", (event) => {
  const limit = Number(event.data?.limit);
  if (!Number.isSafeInteger(limit) || limit < 0) {
    self.postMessage({ error: "invalid limit" });
    return;
  }
  let total = 0;
  for (let value = 0; value < limit; value += 1) total += value;
  self.postMessage(total);
});

Validate message data as untrusted input, handle error and worker termination, and define ownership when transferring buffers. A worker adds startup, serialization, and coordination costs; it is not automatically faster for small work.

Interview questions

  1. Reflow versus repaint? Reflow recalculates geometry; repaint redraws pixels. A change can cause either or both.
  2. Why use requestAnimationFrame? It aligns visual updates with the browser's rendering cycle and is paused/throttled more appropriately than intervals in hidden pages.
  3. Do workers share DOM or ordinary JS objects? No direct DOM access and no shared ordinary object graph; messages are cloned or transferred.
  4. How do you test this? Assert the worker result, invalid input response, error path, termination, and that a main-thread timer/input remains responsive during large work.

Rendering trace lab

Use DevTools Performance to record one interaction, rather than guessing from a requestAnimationFrame callback. This deliberately separates a layout read from a transform write:

js
const panel = document.querySelector("#box");
let pendingX = 0;
let frameId = 0;

function scheduleMove(x) {
  pendingX = x;
  cancelAnimationFrame(frameId);
  frameId = requestAnimationFrame((timestamp) => {
    performance.mark("todo-frame-start");
    const before = panel.getBoundingClientRect().width; // read first
    panel.style.transform = `translateX(${pendingX}px)`; // compositor-friendly write
    performance.mark("todo-frame-end");
    performance.measure("todo-frame", "todo-frame-start", "todo-frame-end");
    console.assert(Number.isFinite(timestamp) && before >= 0);
  });
}

Record: input event, scripting duration, style recalculation, layout, paint, composite, long tasks, and dropped frames. Then compare this version with a loop that writes style.width and immediately reads offsetWidth; the forced read can make pending layout happen inside the handler. Test rapid calls to confirm only the latest frame runs, a hidden tab, a 4x CPU slowdown, and prefers-reduced-motion. A smooth trace is evidence for that device and workload, not a universal timing guarantee.