Finding Elements

Not all selection methods return the same thing. The difference between a live HTMLCollection and a static NodeList can cause bugs that are almost impossible to trace without knowing this distinction.

May 1, 20264 min read2 / 12

Once you have the DOM loaded, the first thing you need to do is get a reference to specific elements. There are several ways to do this, and the differences between them are not just syntax. They affect what kind of object you get back and how that object behaves over time.

The Essentials

  1. Single-element selectors: getElementById and querySelector each return one element or null. Always check for null before using the result.
  2. Multi-element selectors: getElementsByTagName, getElementsByClassName, querySelectorAll, and getElementsByName each return a collection.
  3. Live vs. static collections: getElementsBy* methods return a live HTMLCollection that updates automatically as the DOM changes. querySelectorAll returns a static NodeList that is a frozen snapshot.
  4. Array methods: Live HTMLCollection objects do not support .forEach, .map, or .filter. NodeList from querySelectorAll does. Wrap a live collection with Array.from() to use those methods.

Getting One Element

The two main ways to get a single element are getElementById and querySelector.

getElementById is one of the oldest DOM APIs, from the 1990s. It does exactly what it says: finds the first element whose id attribute matches the string you pass. If no match exists, it returns null.

JavaScript
const header = document.getElementById('main-header'); if (header) { header.textContent = 'Hello'; }

querySelector is more modern and more flexible. You pass it any valid CSS selector and it returns the first matching element. This means you can select by ID, class, tag, attribute, relationship, or any combination thereof.

JavaScript
const firstNavLink = document.querySelector('nav a'); const activeItem = document.querySelector('.menu-item.active'); const submitButton = document.querySelector('form button[type="submit"]');

Both return null if nothing matches. If you call a method on null without checking first, you get a TypeError that can be hard to trace.

Getting Multiple Elements

When you need all elements matching a criterion, you have a few options. The important thing is which type of collection you get back.

getElementsByTagName and getElementsByClassName both return a live HTMLCollection. querySelectorAll returns a static NodeList.

JavaScript
const paragraphs = document.getElementsByTagName('p'); // live HTMLCollection const warnings = document.getElementsByClassName('warning'); // live HTMLCollection const listItems = document.querySelectorAll('ul.menu li'); // static NodeList

If nothing matches, collections are empty rather than null. You will not get a TypeError from iterating an empty collection the way you would from calling a method on null.

The Live Collection Problem

This is the part that trips people up.

A live HTMLCollection is a view into the current DOM. It is not a snapshot taken at the moment you called the function. If the DOM changes after you got the collection, the collection changes too.

Consider this scenario: you have a list where each item has class task. You get all of them with getElementsByClassName('task'). You then loop through them to remove any that are marked complete. As you remove items from the DOM, the collection shrinks. Indices shift. If you are looping with a traditional for loop and using the collection's length as your boundary, you can skip elements because the list contracts under you while you iterate.

JavaScript
const tasks = document.getElementsByClassName('task'); // live for (let i = 0; i < tasks.length; i++) { if (tasks[i].dataset.complete === 'true') { tasks[i].remove(); // the collection is now shorter // i is now pointing at a different element than expected } }

The simplest fix is to use querySelectorAll, which returns a static snapshot:

JavaScript
const tasks = document.querySelectorAll('.task'); // static NodeList, safe to iterate for (let i = 0; i < tasks.length; i++) { if (tasks[i].dataset.complete === 'true') { tasks[i].remove(); // NodeList does not change, indices stay stable } }

Array Methods on Collections

querySelectorAll returns a NodeList, which supports .forEach, .keys, .values, and .entries. You can iterate it directly.

getElementsBy* returns an HTMLCollection, which does not support those methods. This is a historical artifact: the DOM API predates modern array methods and backward compatibility prevents changing the HTMLCollection interface.

If you have a live HTMLCollection and need .map or .filter, wrap it in Array.from:

JavaScript
const items = document.getElementsByClassName('card'); const texts = Array.from(items).map(item => item.textContent);

This creates a real array from the collection at that moment in time, which also solves the live mutation problem. The array you get from Array.from is a snapshot, not a live view.

Scoping Queries

Both querySelector and querySelectorAll are available on every DOM element, not just on document. When you call them on a specific element, they only search within that element's subtree.

JavaScript
const sidebar = document.getElementById('sidebar'); const sidebarLinks = sidebar.querySelectorAll('a'); // only links inside #sidebar

This is useful when you have a known root element and want to avoid accidentally selecting elements from other parts of the page.

Further Reading and Watching

Video:

Practice what you just read.

The Live Collection Trap
1 exercise