DOM and events · 2 / 10
lesson 2

Selecting and traversing

Finding the element you want without re-querying the entire document — and walking up, down, and across the tree.

~ 14 min read·lesson 2 of 10
0 / 10

You have the tree. Now you need to grab a specific node out of it. The page has a button somewhere; you want a reference to that button so you can change its text or listen for clicks. This lesson is about the small set of methods that find what you want and let you walk around once you have it.

Most code only needs four moves: ask the document for an element, ask an element for a closer ancestor, walk to a sibling, or check whether a node matches a selector. Once those four are second nature, almost every "how do I find X" question answers itself.

querySelector and friends

The two methods that do the heavy lifting are querySelector and querySelectorAll. They both take a CSS selector — the same string you would use in a stylesheet — and return matching elements.

find.js
const heading = document.querySelector('h1');
// the first <h1> in the document, or null if there isn't one

const allButtons = document.querySelectorAll('button.primary');
// every <button class="primary"> in the document, as a NodeList

Two things to notice. querySelector returns just the first match, or null if nothing matched. querySelectorAll returns a list of every match, possibly empty. The list is a NodeList — close enough to an array that you can use forEach on it directly, and you can spread it ([...nodes]) if you want a real array.

Because the selector string is just CSS, anything you can write in a stylesheet works here:

examples.js
document.querySelector('#submit');           // by id
document.querySelector('.error-message');     // by class
document.querySelectorAll('input[required]'); // by attribute
document.querySelector('main > h2');          // structural
document.querySelector('li:nth-child(3)');    // pseudo-class

You will see older code use getElementById('submit') or getElementsByClassName('error-message'). Those still work, but they were the only options before querySelector existed. The CSS-selector pair is enough for almost everything; reach for the older methods only if you have a specific reason.

Tip

Always check for null when you use querySelector on something that might not be there. const btn = document.querySelector('#go'); btn.disabled = true; throws a runtime error if the page has no #go.

check your understanding
You write document.querySelectorAll('.tag') and the page has no elements with class tag. What do you get back?

Scoping a search

querySelector is not just on document. Every element has its own copy of the method, and calling it on an element only searches inside that element's subtree. That is the difference between "find any save button on the page" and "find the save button inside this dialog".

scoped.js
const dialog = document.querySelector('#confirm-dialog');
const save = dialog.querySelector('button.save');
// only the .save button inside #confirm-dialog

Why bother? Two reasons. Correctness — if there are several save buttons on the page (one per dialog, one in a toolbar), the unscoped query gets you the wrong one. Performance, sometimes — searching a tiny subtree is cheaper than scanning the whole page. The first reason matters every day; the second only matters in giant pages.

Treat document.querySelector(...) as the root-level lookup. Once you have a section of the page, scope further searches to that section. It scales.

check your understanding
A modal dialog has a "Cancel" button. The page also has a "Cancel" button in a toolbar. Inside the modal's open handler, you write document.querySelector('button.cancel'). Which button do you get?

Walking upward with closest

Selecting downward is one direction. The other direction is upward — you have a node, and you want the nearest ancestor that matches a selector. This comes up constantly. A user clicks a delete icon, but you really want the row that contains it. A user clicks a tab label, but you want the tab panel.

The method for that is closest. Given a CSS selector, it walks up the tree from the node — including the node itself — and returns the first ancestor that matches, or null if it reaches the top without finding one.

closest.js
function onDeleteClick(event) {
const row = event.target.closest('tr');
// event.target might be the icon, the cell, anything inside the row
// closest('tr') walks up until it finds the table row
row?.remove();
}

The trick is that event.target is whatever the user actually clicked — often a deeply nested span or icon — not the row you care about. closest('tr') says "from here, find me the row that owns this click." It is the upward-walking shortcut that nearly every event handler ends up using.

A useful picture: closest('selector') is the same as standing on a step of a staircase and asking "what is the nearest step above me, including this one, that has my friend on it?". You walk up step by step. The first match wins.

Tip

closest includes the element itself. node.closest('.row') returns node if node already has class row. That is usually what you want.

Asking "does this match?"

Sometimes you do not need to find a node — you have one already, and you just want a yes/no on whether it fits a selector. That is what matches does.

matches.js
if (event.target.matches('button.primary')) {
console.log('a primary button was clicked');
}

matches returns a boolean. It is the test version of querySelector. The pair closest + matches together cover most "did the click happen on something I care about?" code you will ever write.

check your understanding
You want to handle clicks on icon buttons inside a navbar, but ignore clicks elsewhere. The handler runs for every click on the navbar. Which check does the job?

Live vs static lists

A subtle gotcha that bites people once and then never again: not all element collections are the same.

querySelectorAll('p') returns a static NodeList — a snapshot from the moment of the call. Add a new <p> to the page right after, and the snapshot does not include it.

document.getElementsByTagName('p') returns a live HTMLCollection — a list that updates itself as the DOM changes. Add a new <p>, and the list grows.

live.js
const staticList = document.querySelectorAll('p');
const liveList = document.getElementsByTagName('p');

document.body.append(document.createElement('p'));

console.log(staticList.length); // unchanged from before
console.log(liveList.length);   // one more

Why does this matter? Because writing a for loop over a live list while also adding new matching nodes inside the loop creates an infinite loop — every iteration adds an item, every iteration the length grows. With a static NodeList, the loop runs over the original snapshot and finishes.

The practical rule: default to querySelectorAll. It returns a static list, behaves predictably under iteration, and is what most code wants. Only reach for the older getElementsBy* methods when you genuinely want the live behavior — which is rare.

Watch out

Looping over a live collection while adding matching elements inside the loop will run forever. If you must mutate while iterating, snapshot first: const snap = [...liveList];.

check your understanding
You write a loop that walks document.getElementsByTagName('div') and inside each iteration appends a new div to the body. The browser hangs. Why?
check your understanding
You handle a click on a card. You need both the card element and the button that was clicked inside it (anywhere — could be the icon, the label, the wrapper). What is the cleanest pair of calls?
← prevnext lesson →
KeepLearningcertificate
for completing
DOM and events
0 of 10 read