069: Selecting and Traversing DOM Elements
Outcomes
By the end of this lesson, you can:
- select one element with
querySelector()orgetElementById(); - select every match with
querySelectorAll(); - write useful CSS selectors for DOM queries;
- explain
null, a staticNodeList, and zero-based indexing; and - scope a query to one part of the page.
Retrieval Warm-Up
- What does the browser build after parsing HTML?
- Is every DOM node an element?
- In
<ul><li>One</li></ul>, what is the parent ofli?
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: querySelectorquerySelectorAll(): Returns a static NodeList of all matching elements. — Source: MDN: querySelectorAllgetElementById(): Returns the unique element bearing that id. — Source: MDN: getElementByIdNodeList: 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:
#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:
<!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:
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:
appsearches the whole document and should be one element.headinguses the ID value without#.firstTasksearches only belowappand returns one element.allTasksis a staticNodeList;lengthis3.:not()excludes completed tasks.- The quoted attribute selector finds high-priority items.
for...ofvisits each element in document order.- The button listener safely writes a status using
textContent. Events are explored deeply in 072–073.
In DevTools Console, try:
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.
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.
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:
<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>
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:
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
#togetElementById: usegetElementById("task-list"), notgetElementById("#task-list"). - Assuming a match exists:
document.querySelector(".missing").textContentthrows because the result isnull. Log the query result first. - Expecting one element from
querySelectorAll: it always returns aNodeList, even for zero or one match. - Calling array-only methods: a
NodeListhasforEach()and iteration, but not every array method. UseArray.from(nodes)or[...nodes]whenmap(),filter(), orfind()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
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
const tasks = document.querySelectorAll("#task-list > .task");
tasks.forEach((task, index) => {
console.log(`${index + 1}. ${task.textContent}`);
});
Professional Extension
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 ornull.querySelectorAll()returns a static, iterableNodeList.getElementById()takes a plain ID and returns one element ornull.- Scoped, stable selectors are easier to maintain than layout-dependent ones.
- Select UI boundaries; keep application data in state.
