Skip to main content

🌳 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 getElementById and querySelector
  • Select groups of elements with getElementsByClassName, getElementsByTagName, and querySelectorAll
  • Distinguish a live HTMLCollection from a static NodeList and know when it matters
  • Traverse the tree with parentElement, children, closest, and matches
  • 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."

graph TD A[document] --> B[html] B --> C[head] B --> D[body] C --> E[title] C --> F[meta] D --> G[header] D --> H[main] D --> I[footer] H --> J[section] H --> K[article] J --> L[h2] J --> M[p]

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 returns the first match; querySelectorAll returns every match document.querySelectorAll('.card') .card match 1 .card match 2 .card match 3 β†’ NodeList of ALL three querySelector returns only the FIRST match only
Same selector, two results: 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.

MethodReturnsLive or Static?Has forEach?Best for
getElementByIdsingle elementβ€”β€”one unique element (fastest)
getElementsByClassNameHTMLCollectionLiveNoa class group that may change
getElementsByTagNameHTMLCollectionLiveNoall elements of a type
querySelectorsingle elementβ€”β€”complex, precise targets
querySelectorAllNodeListStaticYesa 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/querySelectorAll first β€” one CSS syntax covers every case
  • Use getElementById when 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 a NodeList or HTMLCollection β€” convert with Array.from() or [...collection] first
  • Add or remove elements while looping over a live collection
  • Pass a # to getElementById or a bare id to querySelector β€” 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
  • getElementById is the fastest single-element lookup; querySelector is the most flexible
  • querySelectorAll returns a static NodeList; the getElementsBy… methods return live HTMLCollections
  • Convert collections with Array.from() before using map/filter/reduce
  • Walk the tree with parentElement, children, closest(), and matches() 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.