FullStack Course LogoFullStack Course
Module: JavaScript
JavaScript·069·9 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

Before working with selectors, retrieve the tree model from the previous lesson:

  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?

These questions matter here because DOM selection is a way of asking a structured tree for particular nodes. If the tree model is unclear, selector results can feel arbitrary when they are not.

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, and a selector is a precise question about that tree. For example, you might ask for the first element with a particular ID, or for every list item below a particular list. The browser evaluates the selector against the relevant part of the tree and gives you the matching element or collection.

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 */

Choose the selector according to what the markup means. 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 tied to incidental layout, such as main > div:nth-child(2). A small markup change can alter that position and silently make the query select the wrong element.

The useful distinction between the three main APIs is straightforward. document.querySelector(selector) stops at the first match. document.querySelectorAll(selector) takes a snapshot containing every match. document.getElementById(id) is a direct, readable choice when the target has a unique ID; unlike a CSS selector, its argument is the ID value without #.

Self-Study Example: Select a Todo Dashboard

Start with this complete page. The defer attribute lets the browser load the script without blocking HTML parsing and runs it after the document has been parsed, so the elements are available during setup.

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 the DevTools Console, try these expressions:

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

The first index is 0, not 1. A missing indexed item is undefined, while item(99) returns null. There is a second result worth taking seriously: a single-element query can also return null. If a match is uncertain, do not immediately access a property on the result. Check that the element exists or structure the code so the missing-element case is handled deliberately.

Static Does Not Mean Frozen

The NodeList returned by querySelectorAll() is static, but the element objects inside it are still live object references. If an existing task's text changes, allTasks[0] refers to that same element and reflects the changed text. Static describes the membership of the collection, not a frozen copy of every element.

That distinction becomes visible when the document gains a new matching element. If a fourth matching <li> is appended later, the old allTasks.length remains 3. Query again when you need a snapshot of the new membership.

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 predictable behavior is useful during rendering: a loop iterates over the set that existed when the query ran. It will not unexpectedly start processing elements added halfway through because the old collection quietly changed underneath it.

Architecture Connection

Selection gives the code references to stable UI boundaries such as the form, list, filter controls, and status area. Select those persistent boundaries once during setup when that is appropriate. The selected DOM nodes are handles to the interface, not the application's state itself.

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

Keeping those responsibilities separate makes later updates easier to reason about. Task IDs belong in state. Data attributes on rendered controls can then help an event identify which state item should change, without turning the DOM into the source of truth.

Intermediate Example: Query Within Each Section

Suppose the page has two lists, both containing .task items. A document-wide query sees both groups and mixes their results. When the question is "which tasks belong to this section?", start with the section and scope the query to its subtree:

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. In this example, .task alone would also find the intended descendants. However, :scope > ul > .task can express an exact direct relationship and avoid accidentally reaching a nested task group. Scoping is therefore useful both for correctness and for making the boundary of the query visible to the next developer reading it.

Optional Advanced Example: User Values in Selectors

Dynamic selectors have an edge case that is easy to overlook. Do not interpolate arbitrary values into a selector without escaping them. A value containing ?, quotes, brackets, or other selector syntax can make the selector invalid or change what it means. When possible, compare data in JavaScript instead of manufacturing a selector from a value that may come from a user or another untrusted source.

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 this Todo app, tasks.find((task) => task.id === id) will usually be clearer than constructing a complex selector from user-controlled text. CSS.escape() makes the identifier safe to place in CSS selector syntax; it does not turn arbitrary content into HTML or sanitize it for every other context.

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 debugging sequence is small and observable. First paste the selector into DevTools as document.querySelectorAll(...). Check length to distinguish no matches from multiple matches. Inspect the first result and its surrounding markup, then narrow or broaden the selector based on what the DOM actually contains. This separates a selector problem from a timing problem, malformed markup, or a query that is simply scoped to the wrong container.

Accessibility, Security, and Performance

Accessibility: selection does not add semantics. Start with real buttons, headings, lists, labels, and landmarks in the HTML. A class such as .button on a div does not provide keyboard behavior or button semantics. In the example, the actual button works with keyboard and pointer input, and the role="status" output can announce changed text without moving focus.

Security: selecting elements 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 a different context. Treat escaping as context-specific rather than as a general-purpose sanitization step.

Performance: selector calls are fast for ordinary pages, so correctness and maintainability should lead the decision. 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 that they update. Measure real performance problems before adding complexity.

Exercises

Core

Using the Todo dashboard, write selectors for the summary, all incomplete tasks, and only high-priority incomplete tasks.

Practice

Log each task in this format: 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)',
);

These selectors express the requested constraints directly: one class for the summary, :not() for incomplete tasks, and both the priority attribute and completion exclusion for urgent incomplete tasks.

Practice

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

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

The collection index starts at 0, so adding 1 is what produces the human-friendly numbering.

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

The new query runs when the button is clicked, so it sees the fourth task instead of reusing the earlier static snapshot. 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

Reader page: /javascript/lesson/069/selecting-and-traversing-dom-elements