π³ Selecting DOM Elements
A web page you can see is really a tree of objects the browser built from your HTML β the DOM. Before JavaScript can change anything on that page, it has to find the right element first. This lesson is your toolkit for locating any node in that tree, from a single button to every row in a table.
Week 2 · Day 2 (Tuesday: DOM Manipulation) · Lecture 1
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain what the DOM is and picture a page as a tree of nodes
- Select a single element with
getElementByIdandquerySelector - Select groups of elements with
getElementsByClassName,getElementsByTagName, andquerySelectorAll - Distinguish a live
HTMLCollectionfrom a staticNodeListand know when it matters - Traverse the tree with
parentElement,children,closest, andmatches - Write safe, performant selection code that never crashes on a missing element
Estimated Time: 60 minutes
Practice: Build a small set of reusable selection helpers and query a live page with them.
In This Lesson
What Is the DOM?
When the browser loads your HTML, it doesn't keep the text β it builds a living model of the page in memory called the Document Object Model. Every tag becomes a node, and those nodes nest inside one another exactly like your HTML nested its tags. The result is a tree, with the document at the root and every element hanging off it as a branch or leaf.
Think of it as a family tree. <body> is a parent; the <header>, <main>, and <footer> inside it are its children; and a paragraph deep inside a section is a distant descendant. Selecting an element is just asking, "give me the person at this spot in the family."
Because the DOM is a live object graph β not the original text β anything you change through JavaScript updates the page instantly. But you can only change a node once you hold a reference to it, which is why selection comes first.
Classic Selection Methods
The original DOM API gives you three targeted lookups. They're fast and universally supported, and you'll still see them everywhere.
getElementById() β one exact element
The fastest lookup there is: you know the element's unique id and ask for it by name. It returns the single matching element, or null if nothing matches.
// HTML: <div id="header">Welcome</div>
const header = document.getElementById('header');
console.log(header.textContent); // "Welcome"
// Not found? You get null β never an error:
const missing = document.getElementById('does-not-exist');
console.log(missing); // null
// Note: pass the raw id, WITHOUT a leading "#"
// getElementById('#header') would look for id="#header" and fail
getElementsByClassName() β a live group by class
Returns an HTMLCollection of every element carrying the class. It's live: if the page gains or loses a matching element later, the collection updates itself.
// HTML: three <div class="card">β¦</div> elements, one also .special
const cards = document.getElementsByClassName('card');
console.log(cards.length); // 3
console.log(cards[0].textContent); // first card
// Pass multiple classes (space-separated) to require ALL of them:
const specialCards = document.getElementsByClassName('card special');
console.log(specialCards.length); // 1
// "Live" in action β add a matching element and the count rises on its own:
const newCard = document.createElement('div');
newCard.className = 'card';
document.body.appendChild(newCard);
console.log(cards.length); // 4 β updated automatically
getElementsByTagName() β a live group by tag
Same idea, but it matches by element type: every <p>, every <li>, and so on. Also live.
const paragraphs = document.getElementsByTagName('p');
console.log(paragraphs.length);
// You can scope the search to inside one element:
const content = document.getElementById('content');
const contentParagraphs = content.getElementsByTagName('p');
// An HTMLCollection is array-LIKE, not an array. To use map/filter,
// convert it first:
Array.from(paragraphs).forEach(p => console.log(p.textContent));
π "Element" vs "Elements"
Notice the naming: getElementById is singular and returns one element. getElementsByClassName and getElementsByTagName are plural and return a collection β even when only one thing matches. That s tells you what you're getting back.
The querySelector Family
The modern approach lets you use the full power of CSS selectors β the exact same syntax you write in a stylesheet. One mental model covers every case, which is why these two methods have largely replaced the classics for new code.
querySelector() β the first match
Returns the first element that matches your CSS selector, or null if none do.
// The same three classic lookups, in CSS-selector form:
document.querySelector('#header'); // by id (note the "#")
document.querySelector('.card'); // by class (note the ".")
document.querySelector('p'); // by tag
// β¦but now you can express far more precise targets:
document.querySelector('nav li.active'); // descendant + class
document.querySelector('article > h2'); // direct child
document.querySelector('button[type="submit"]'); // attribute match
document.querySelector('input[type="email"]'); // attribute value
document.querySelector('ul li:first-child'); // pseudo-class
document.querySelector('.container .content p'); // deep descendant
// No match still means null β check before you use it:
console.log(document.querySelector('.nope')); // null
querySelectorAll() β every match
Returns a static NodeList of all matching elements. Unlike an HTMLCollection, a NodeList has a built-in forEach.
const allCards = document.querySelectorAll('.card');
// NodeList has forEach out of the box:
allCards.forEach((card, index) => {
card.classList.toggle('even-row', index % 2 === 0);
});
// Combine selectors with commas to match ANY of them:
document.querySelectorAll('.important, .urgent, #critical');
// Match on data attributes:
document.querySelectorAll('[data-category="electronics"]');
// Match checked checkboxes with a pseudo-class:
document.querySelectorAll('input[type="checkbox"]:checked');
// For map/filter/reduce, convert to a real array:
const activeCards = Array.from(allCards)
.filter(card => card.classList.contains('active'));
querySelector stops at the first hit, querySelectorAll collects them all.Live vs Static Collections
Every "get many elements" call returns one of two kinds of collection. The difference trips up beginners constantly, so let's make it concrete.
| Method | Returns | Live or Static? | Has forEach? | Best for |
|---|---|---|---|---|
getElementById | single element | β | β | one unique element (fastest) |
getElementsByClassName | HTMLCollection | Live | No | a class group that may change |
getElementsByTagName | HTMLCollection | Live | No | all elements of a type |
querySelector | single element | β | β | complex, precise targets |
querySelectorAll | NodeList | Static | Yes | a fixed snapshot to loop over |
A live collection is a window onto the DOM: it re-checks itself whenever the page changes. A static collection is a photograph taken at the moment you called it β it never updates.
// LIVE β reflects later changes
const live = document.getElementsByClassName('item');
console.log(live.length); // 3
document.body.appendChild(makeItem());
console.log(live.length); // 4 β the collection noticed
// STATIC β a snapshot, frozen in time
const snapshot = document.querySelectorAll('.item');
console.log(snapshot.length); // 3
document.body.appendChild(makeItem());
console.log(snapshot.length); // 3 β still 3, unchanged
function makeItem() {
const el = document.createElement('div');
el.className = 'item';
return el;
}
β οΈ The live-collection loop trap
Looping over a live collection while modifying it can skip or repeat elements β the collection shifts under you mid-loop. When you plan to add or remove nodes as you go, take a static snapshot first with querySelectorAll or Array.from(...), then iterate that.
Traversing the Tree
Once you hold one element, you can walk to its relatives without a fresh query. This is often cleaner and faster than writing an ever-more-specific selector.
const el = document.querySelector('.target');
// Up to a parent:
el.parentElement; // nearest element parent
el.closest('section'); // nearest ancestor matching a selector
el.closest('.container'); // (walks up until it matches, or null)
// Down to children (elements only, no text/whitespace nodes):
el.children; // HTMLCollection of element children
el.firstElementChild;
el.lastElementChild;
// Sideways to siblings:
el.nextElementSibling;
el.previousElementSibling;
// Ask a yes/no question about an element itself:
if (el.matches('.active')) {
console.log('this element has the active class');
}
π‘ closest() is your friend for event handling
When a user clicks a small icon inside a big card, the click lands on the icon β but you usually want the whole card. event.target.closest('.card') walks up from wherever the click happened to the card that contains it. You'll lean on this constantly once you reach event delegation.
Element nodes vs all nodes
Prefer the ...Element... versions (children, firstElementChild, nextElementSibling). The older childNodes, firstChild, and nextSibling also count text nodes β including the whitespace and line breaks between your tags β which is almost never what you want.
Selecting Safely
The single most common DOM error is Cannot read properties of null β you selected something that wasn't there, then tried to use it. Guard against it.
// β Crashes if nothing matches:
document.querySelector('.maybe').classList.add('active');
// β
Check first:
const el = document.querySelector('.maybe');
if (el) {
el.classList.add('active');
}
// β
Or use optional chaining β do nothing if it's null:
document.querySelector('.maybe')?.classList.add('active');
A reusable helper
Many projects define a tiny shorthand for "query all, as a real array." It saves the Array.from dance every time.
// $$ = "select all matching, return a proper array"
const $$ = (selector, context = document) =>
Array.from(context.querySelectorAll(selector));
// Now you get map/filter/reduce for free:
const buttonLabels = $$('button').map(b => b.textContent);
const formInputs = $$('input', document.getElementById('myForm'));
Output
document.querySelector('#nope') β null
document.querySelector('#nope')?.id β undefined (no crash)
$$('button').length β 3
Practice & Quiz
ποΈ Exercise 1: Links that open a new tab
Goal: Write findNewTabLinks() that returns an array of every <a> on the page whose target is _blank.
function findNewTabLinks() {
// TODO: select all anchors with target="_blank", return them as an array
}
console.log(findNewTabLinks()); // e.g. [a, a, a]
π‘ Hint
An attribute selector a[target="_blank"] matches exactly those links. Wrap the querySelectorAll result in Array.from(...) so the caller gets a real array.
β Solution
function findNewTabLinks() {
return Array.from(document.querySelectorAll('a[target="_blank"]'));
}
ποΈ Exercise 2: Find the deepest element
Goal: Write findDeepestElement() that returns the single element nested most levels deep in the <body>. This exercises traversal via children.
π‘ Hint
Walk the tree recursively, tracking the current depth. Whenever you reach a node deeper than any seen so far, remember it. Recurse into element.children.
β Solution
function findDeepestElement(root = document.body) {
let deepest = root;
let maxDepth = 0;
(function walk(el, depth) {
if (depth > maxDepth) {
maxDepth = depth;
deepest = el;
}
for (const child of el.children) {
walk(child, depth + 1);
}
})(root, 0);
return deepest;
}
π― Quick Quiz
Question 1: Which call returns a live collection that updates when the page changes?
Question 2: What does document.querySelector('.missing') return when nothing matches?
Question 3: You clicked an icon inside a card and want the whole card element. Which is the cleanest tool?
Best Practices & Pitfalls
β Do
- Reach for
querySelector/querySelectorAllfirst β one CSS syntax covers every case - Use
getElementByIdwhen you have a unique id and want the fastest possible lookup - Cache a selection in a variable instead of re-querying the same element repeatedly
- Scope searches to a container (
container.querySelectorAll(...)) instead of scanning the whole document - Guard every lookup that might miss with
if (el)or optional chaining?.
β Don't
- Call
.map()straight on aNodeListorHTMLCollectionβ convert withArray.from()or[...collection]first - Add or remove elements while looping over a live collection
- Pass a
#togetElementByIdor a bare id toquerySelectorβ the syntax differs - Use the universal selector
*when a specific tag or class would do
β Cache your selections
// β queries the DOM three times for the same element
document.querySelector('.status').textContent = 'Loadingβ¦';
document.querySelector('.status').textContent = 'Done';
document.querySelector('.status').classList.add('success');
// β
one lookup, reused
const status = document.querySelector('.status');
status.textContent = 'Loadingβ¦';
status.textContent = 'Done';
status.classList.add('success');
Summary
π Key Takeaways
- The DOM is a live tree of objects the browser builds from your HTML
getElementByIdis the fastest single-element lookup;querySelectoris the most flexiblequerySelectorAllreturns a staticNodeList; thegetElementsByβ¦methods return liveHTMLCollections- Convert collections with
Array.from()before usingmap/filter/reduce - Walk the tree with
parentElement,children,closest(), andmatches()instead of re-querying - Always guard lookups that might return
null
π Additional Resources
π What's Next?
Now that you can find any element on the page, the next lesson teaches you to change it: Modifying Elements and Attributes β updating text and HTML, restyling with classList, and reading and writing attributes and data.
π Nicely done!
Selection is the doorway to every interactive feature you'll ever build. From here, the page is yours to reshape.