FullStack Course LogoFullStack Course
Module: JavaScript
JavaScript·078·11 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 should be able to:

  • derive active, completed, and all-task views from one task array;
  • render useful empty states and accurate counts;
  • refactor Todo code around state, rendering, events, and persistence;
  • restore or redirect focus after destructive DOM changes;
  • test accessibility, security, storage failure, and responsive behavior; and
  • diagnose bugs by tracing the application's one-way data flow.

Retrieval Warm-Up

Before changing the app, retrieve the core model from the previous lesson:

  1. Recite the Todo app's event-to-render cycle.
  2. Why should a filter be a view of the tasks rather than a second task array?
  3. What should the application do 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 instead of being stored independently (course term).
  • Empty state: Content that explains why a region contains no items and tells the user what action is available (course term).
  • Refactor: An improvement to internal structure that does not intentionally change behavior (course term).
  • Regression: Existing behavior that stops working after a change (course term).
  • Focus management: Deliberately moving or restoring keyboard focus after DOM changes. — Source: MDN: HTMLElement.focus()
  • Accessibility tree: The assistive-technology-facing structure derived from the DOM that exposes names, roles, and 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 finished application only needs two pieces of state: one task array and one filter:

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

This is the useful distinction: tasks are source data, while the active and completed views are calculated from that source. Do not maintain separate allTasks, activeTasks, and completedTasks arrays. Those copies can drift as soon as one update changes one array but not another.

Use this flow instead:

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

The broader architecture has not changed from the earlier Todo app:

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

Changing a filter changes only view state, so it does not need a storage write. Adding, toggling, deleting, or clearing completed tasks changes persistent task state, so each operation attempts a write.

Self-Study Example: Finished Accessible Todo

Start from lesson 077. Replace the content inside <main class="app"> with this 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 the base CSS from 066 and append these rules:

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 this as the 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();

Now exercise the completed app rather than judging it from the visual output alone. Use the entire interface with the keyboard. Add duplicate titles, text that looks like markup, and an 80-character title. Visit every filter while it is empty. Complete an active task and confirm that it leaves the Active view; focus should move to the current filter control instead of disappearing. Delete the middle task and check that focus lands on a nearby Delete button. Finally, clear completed tasks and verify that focus returns to the title input, not to the Clear completed button that render() has just disabled.

Filtering and Empty States

getVisibleTasks() is a derived selector. It reads state and returns the appropriate view; it does not change the source state. That is why switching filters cannot lose tasks: state.tasks remains complete throughout.

There are three useful empty-state cases:

  • No source tasks: explain how the user can add the first task.
  • Empty Active view: tasks exist, but they may all be complete.
  • Empty Completed view: no task has been completed.

The message is ordinary visible text, not a placeholder inserted into an invalid list. When the list is shown, the ul contains only li elements. The browser's built-in hidden presentation removes an 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 boundaries that are easy to name and inspect:

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

commit() gathers one repeated sequence: save, report the result, and render. It stays deliberately small. Filtering calls render() directly because it changes only the view and should not persist task data. That is a useful refactor, not an abstraction added for its own sake.

Do not turn every three lines into a helper. Extract a function when it has a coherent name, is reused, or can benefit from isolated tests.

Intermediate Example: Pure Filter Tests

The derivation logic can be made 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");

getVisibleTasks() can then return filterTasks(state.tasks, state.filter). Small pure functions make regression checks inexpensive because they can be tested without creating a DOM or manipulating storage.

Optional Advanced Example: Confirm Bulk Destruction

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

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

A stronger product design would offer Undo. Do not build a custom modal until its labeling, focus behavior, Escape behavior, and focus return are all correct.

Mistakes and Debugging

  • Filtering by deleting: Filtering is a display derivation, not a destructive state update.
  • Persisting filtered arrays: Save the complete task state, not only the current view.
  • No filter allowlist: Validate UI and storage values against the known filter options.
  • Putting plain text directly in ul: Use an external empty-state paragraph or a valid li.
  • Losing focus after render: When the focused node disappears, deliberately select the next logical control.
  • Announcing too much: The status region should report outcomes, not repeat every visible DOM change.
  • Disabling with CSS only: Set 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: Keep a manual test checklist and change one boundary at a time.

Follow the data flow when debugging. Inspect state.tasks and state.filter, call getVisibleTasks(), run render() manually, inspect the DOM and accessibility tree, and only then inspect persistence. A stale view points toward rendering or derivation. A task that returns after reload points toward save or load. A double action points toward listener registration or event propagation.

Accessibility Audit

Test these behaviors instead of inferring them from the markup:

  • Navigate every control with Tab/Shift+Tab and activate it with the keyboard.
  • Confirm that visible focus is not obscured and that the order follows DOM order.
  • Confirm that every input has a label and every button has a specific accessible name.
  • At 200% and 400% zoom, check reflow without horizontal two-dimensional scrolling.
  • Check text contrast, control and focus contrast, and a state indicator that does not rely on color alone.
  • Inspect the accessibility tree for the list, checkbox names and states, the group and legend, the status region, and the disabled Clear button.
  • Confirm that status updates are announced without stealing focus.
  • Confirm that completing or deleting a filtered task leaves focus in a logical place.
  • Test touch-sized targets and the 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 requiring a custom tab widget.

Security, Privacy, and Performance Audit

Security/privacy: No task value is inserted through innerHTML; the implementation uses textContent. Loaded payloads are parsed inside try/catch and checked against the schema. Action and filter values use allowlists and current-state lookups. Sensitive information does not belong in storage. If this becomes server-backed, authenticate the user, authorize each task operation, validate again on the server, encode output safely, and handle conflicts.

Performance: Each handler causes at most one meaningful render and one persistent transition. Filter changes do not write to storage. Keep synchronous storage payloads small. Replacing the full list is reasonable for a personal list; profile before changing that approach. Avoid layout reads and writes interleaved inside loops. Larger data sets call for pagination or 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% and 400% zoom, and a narrow viewport. A list that looks correct is not proof that state and storage agree.

Core

Add an All done message when tasks exist but activeCount === 0. Do this without replacing the 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 with an Undo button that is normally hidden. Restore the removed tasks, save, render, announce the result, and return focus.

Core

Add <p id="completion-message" role="status"></p> near the summary, select it, and add this to render():

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

Practice

Create and 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, put this before the 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 mechanism. A labeled inline edit form is a better product interface because it gives the user a persistent label, validation feedback, and predictable focus behavior.

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 from it.
  • Render explicit source-empty and filter-empty states.
  • Separate persistence, state and derivation, rendering, and event transitions.
  • Save persistent state changes; render view-only filter changes directly.
  • Manage focus whenever rendering removes the active control.
  • Audit keyboard use, semantics, status messages, contrast, zoom, storage failure, untrusted text, and performance.
  • The final architecture is still state -> render -> event -> update -> save -> render.

Official References

Rendering, layout, and frame scheduling

When the browser handles a page, it parses HTML and 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 depends on the browser, so treat these as useful categories rather than promises about every internal step.

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 a better fit for animation than a fast interval, 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 instead of 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 to inspect 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, allowing CPU-heavy work to 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 transfer ownership instead of copying the data.

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

Treat message data as untrusted input. Handle the error path and worker termination, and define ownership when transferring buffers. A worker also introduces startup, serialization, and coordination costs, so it is not automatically faster for small jobs.

Interview questions

  1. Reflow versus repaint? Reflow recalculates geometry; repaint redraws pixels. One change can cause either or both.
  2. Why use requestAnimationFrame? It aligns visual updates with the browser's rendering cycle and is paused or throttled more appropriately than intervals in hidden pages.
  3. Do workers share the DOM or ordinary JavaScript objects? No. They have no direct DOM access and do not share an 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 or input remains responsive during large work.

Rendering trace lab

Use the DevTools Performance panel to record one interaction instead of guessing from a requestAnimationFrame callback. This example intentionally keeps a layout read separate 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 the input event, scripting duration, style recalculation, layout, paint, composite work, long tasks, and dropped frames. Compare this version with a loop that writes style.width and immediately reads offsetWidth; that forced read can make pending layout happen inside the handler. Test rapid calls to confirm that only the latest frame runs, then test 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.

Reader page: /javascript/lesson/078/dom-project-ii-refactor-accessibility-and-performance