Module: JavaScript
JavaScript·069·6 MIN READ

069: Selecting and Traversing DOM Elements

TOPICS COVERED: Selecting and Traversing DOM Elements

Outcomes

By the end of this lesson, you can:

  • select one element with querySelector() or getElementById();
  • select every match with querySelectorAll();
  • write useful CSS selectors for DOM queries;
  • explain null, a static NodeList, and zero-based indexing; and
  • scope a query to one part of the page.

Retrieval Warm-Up

  1. What does the browser build after parsing HTML?
  2. Is every DOM node an element?
  3. In <ul><li>One</li></ul>, what is the parent of li?

Terms

  • Selector: CSS-style pattern locating elements for scripting. — Source: WHATWG DOM: Selectors
  • querySelector(): Returns the first element matching the selector within the scope root. — Source: MDN: querySelector
  • querySelectorAll(): Returns a static NodeList of all matching elements. — Source: MDN: querySelectorAll
  • getElementById(): Returns the unique element bearing that id. — Source: MDN: getElementById
  • NodeList: Array-like collection of nodes returned by selection APIs. — Source: MDN: NodeList
  • Static collection: Snapshot list that does not update when the document changes. — Source: MDN: NodeList
  • Scope: The element whose subtree querySelector/searches operate within. — Source: MDN: querySelector
  • Index: Zero-based position used to access collection members. — Source: MDN: Array
  • Static collection (official): "A static NodeList does not update when the document changes; it is a snapshot." — Source: MDN: NodeList
  • Scope (selector): "The scope limits where querySelector searches, starting from a given element." — Source: MDN: querySelector
  • Index (official): "An index is the integer position of an item in an ordered collection, starting at 0." — Source: MDN: Array — Index

Mental Model: Ask the Tree a Precise Question

The DOM is a large tree. A selector is a question such as "find the first element with this ID" or "find all list items inside this list."

CSS and DOM selection share syntax:

css
#task-list             /* an ID */
.task                  /* a class */
button                 /* an element type */
[data-action="delete"] /* an attribute/value */
#task-list > li        /* direct children */
.task.is-complete      /* both classes */

Use an ID for one unique landmark, a class for a reusable category, and a data attribute for JavaScript-oriented metadata or actions. Avoid selectors coupled to incidental layout, such as main > div:nth-child(2), because small markup changes break them.

document.querySelector(selector) stops at the first match. document.querySelectorAll(selector) takes a snapshot of all matches. document.getElementById(id) is a direct, clear option for a unique ID; its argument has no #.

Self-Study Example: Select a Todo Dashboard

Use this complete page:

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Selecting todo elements</title>
    <script src="app.js" defer></script>
  </head>
  <body>
    <main>
      <h1>My tasks</h1>
      <section id="todo-app" aria-labelledby="list-heading">
        <h2 id="list-heading">Today</h2>
        <p class="summary">Three tasks</p>
        <ul id="task-list">
          <li class="task" data-priority="high">Learn selectors</li>
          <li class="task is-complete" data-priority="low">Inspect the DOM</li>
          <li class="task" data-priority="high">Practise queries</li>
        </ul>
        <button id="show-count" type="button">Show task count</button>
        <p id="output" role="status"></p>
      </section>
    </main>
  </body>
</html>

Add app.js:

js
const app = document.querySelector("#todo-app");
const heading = document.getElementById("list-heading");
const firstTask = app.querySelector(".task");
const allTasks = app.querySelectorAll(".task");
const openTasks = app.querySelectorAll(".task:not(.is-complete)");
const urgentTasks = app.querySelectorAll('[data-priority="high"]');

console.log({ app, heading, firstTask });
console.log("All:", allTasks.length);
console.log("Open:", openTasks.length);
console.log("Urgent:", urgentTasks.length);

for (const task of allTasks) {
  console.log(task.textContent);
}

const countButton = document.querySelector("#show-count");
const output = document.querySelector("#output");

countButton.addEventListener("click", () => {
  output.textContent = `${allTasks.length} tasks are currently rendered.`;
});

Read the code in this order:

  1. app searches the whole document and should be one element.
  2. heading uses the ID value without #.
  3. firstTask searches only below app and returns one element.
  4. allTasks is a static NodeList; length is 3.
  5. :not() excludes completed tasks.
  6. The quoted attribute selector finds high-priority items.
  7. for...of visits each element in document order.
  8. The button listener safely writes a status using textContent. Events are explored deeply in 072–073.

In DevTools Console, try:

js
allTasks[0]
allTasks.item(1)
allTasks[99]
document.querySelector(".missing")

The first index is 0; a missing indexed item is undefined, while item(99) returns null. Most importantly, a single-element query can return null, so code must not blindly access a property when a match is uncertain.

Static Does Not Mean Frozen

The NodeList from querySelectorAll() is static, but its element objects are still live objects. If an existing task's text changes, allTasks[0] reflects that changed element. However, if a fourth matching <li> is appended later, the old allTasks.length remains 3. Query again to take a new snapshot.

js
const before = document.querySelectorAll(".task");
const extra = document.createElement("li");
extra.classList.add("task");
extra.textContent = "New task";
document.querySelector("#task-list").append(extra);

console.log(before.length); // 3
console.log(document.querySelectorAll(".task").length); // 4

That predictability is useful during rendering: a loop iterates the set that existed when the query ran.

Architecture Connection

Selection gives code references to stable UI boundaries: the form, list, filter controls, and status area. Do that once during setup when elements persist. Do not treat selected DOM nodes as application state.

text
state -> render into selected containers
event on selected controls -> update state -> render again

Later, task IDs live in state. Data attributes on rendered controls will help an event identify which state item should change.

Intermediate Example: Query Within Each Section

Imagine two lists with .task items. A document-wide query mixes them. Scope each query instead:

html
<section class="task-group" aria-labelledby="work-heading">
  <h2 id="work-heading">Work</h2>
  <ul><li class="task">Reply to email</li></ul>
</section>
<section class="task-group" aria-labelledby="home-heading">
  <h2 id="home-heading">Home</h2>
  <ul><li class="task">Water plants</li><li class="task">Cook</li></ul>
</section>
js
const groups = document.querySelectorAll(".task-group");

for (const group of groups) {
  const heading = group.querySelector("h2");
  const tasks = group.querySelectorAll(":scope .task");
  console.log(`${heading.textContent}: ${tasks.length}`);
}

:scope explicitly anchors the selector to group. Here .task alone would also work, but :scope > ul > .task can require exact direct relationships without accidentally reaching a nested task group.

Optional Advanced Example: User Values in Selectors

Do not interpolate arbitrary values into a selector without escaping. A value containing ?, quotes, or brackets can make the selector invalid or change its meaning. Prefer comparing data in JavaScript. If a selector is necessary, use CSS.escape() for an identifier:

js
function findTaskById(id) {
  return document.querySelector(`#${CSS.escape(id)}`);
}

console.log(findTaskById("task?42"));

For our Todo app, tasks.find((task) => task.id === id) will be clearer than manufacturing complex selectors from user-controlled text.

Mistakes and Debugging

  • Forgetting selector punctuation: querySelector("task-list") searches for a <task-list> element; use "#task-list" for an ID.
  • Adding # to getElementById: use getElementById("task-list"), not getElementById("#task-list").
  • Assuming a match exists: document.querySelector(".missing").textContent throws because the result is null. Log the query result first.
  • Expecting one element from querySelectorAll: it always returns a NodeList, even for zero or one match.
  • Calling array-only methods: a NodeList has forEach() and iteration, but not every array method. Use Array.from(nodes) or [...nodes] when map(), filter(), or find() is genuinely useful.
  • Keeping a stale snapshot: query again after structural changes when you need the new set.
  • Invalid dynamic selectors: the browser throws SyntaxError. Keep selectors constant or escape dynamic identifiers.
  • Duplicate IDs: IDs must be unique. A selector returns only the first match, hiding malformed markup.

A productive debug sequence is: paste the selector into DevTools with document.querySelectorAll(...), check length, inspect the first result, then narrow or broaden the selector.

Accessibility, Security, and Performance

Accessibility: selection does not add semantics. Choose real buttons, headings, lists, labels, and landmarks in HTML first. Never use a class such as .button on a div as a substitute for keyboard behavior. The example's visible button works with keyboard and pointer input and the role="status" output can announce its changed text without moving focus.

Security: selecting is normally safe, but building selectors from untrusted values can throw or match unintended elements. CSS.escape() handles CSS identifier escaping; it does not sanitize HTML or make a value safe for another context.

Performance: selector calls are fast for ordinary pages. Scope queries to a stable container for clarity and avoid querying the entire document repeatedly inside large loops. Cache long-lived UI boundaries, but do not cache dynamic NodeList snapshots and assume they update. Correctness first; measure real performance problems.

Exercises

Core

Write selectors for the summary, all incomplete tasks, and only high-priority incomplete tasks.

Practice

Log each task as 1. Learn selectors, 2. Inspect the DOM, and so on.

Professional Extension

Add a fourth task after the original query, then make the count button report the current DOM count rather than the stale snapshot count.

Core

js
const summary = document.querySelector(".summary");
const incomplete = document.querySelectorAll(".task:not(.is-complete)");
const urgentIncomplete = document.querySelectorAll(
  '.task[data-priority="high"]:not(.is-complete)',
);

Practice

js
const tasks = document.querySelectorAll("#task-list > .task");

tasks.forEach((task, index) => {
  console.log(`${index + 1}. ${task.textContent}`);
});

Professional Extension

js
const list = document.querySelector("#task-list");
const extra = document.createElement("li");
extra.classList.add("task");
extra.textContent = "Review NodeList behavior";
list.append(extra);

countButton.addEventListener("click", () => {
  const currentTasks = app.querySelectorAll(".task");
  output.textContent = `${currentTasks.length} tasks are currently rendered.`;
});

Remove the original count listener first; otherwise both listeners run. 061 explains listener lifecycle.

Recap

  • DOM query methods use CSS selector syntax.
  • querySelector() returns the first match or null.
  • querySelectorAll() returns a static, iterable NodeList.
  • getElementById() takes a plain ID and returns one element or null.
  • Scoped, stable selectors are easier to maintain than layout-dependent ones.
  • Select UI boundaries; keep application data in state.

Official References