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.
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
- Single-element selectors:
getElementByIdandquerySelectoreach return one element ornull. Always check fornullbefore using the result. - Multi-element selectors:
getElementsByTagName,getElementsByClassName,querySelectorAll, andgetElementsByNameeach return a collection. - Live vs. static collections:
getElementsBy*methods return a liveHTMLCollectionthat updates automatically as the DOM changes.querySelectorAllreturns a staticNodeListthat is a frozen snapshot. - Array methods: Live
HTMLCollectionobjects do not support.forEach,.map, or.filter.NodeListfromquerySelectorAlldoes. Wrap a live collection withArray.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.
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.
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.
const paragraphs = document.getElementsByTagName('p'); // live HTMLCollection
const warnings = document.getElementsByClassName('warning'); // live HTMLCollection
const listItems = document.querySelectorAll('ul.menu li'); // static NodeListIf 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.
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:
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:
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.
const sidebar = document.getElementById('sidebar');
const sidebarLinks = sidebar.querySelectorAll('a'); // only links inside #sidebarThis 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
- MDN: querySelector - Full reference including the CSS selector syntax it accepts.
- MDN: HTMLCollection vs NodeList - Explains the live vs. static distinction and what methods each supports.
Video:
- JavaScript DOM Crash Course - Part 1 by Traversy Media. Covers
getElementById,querySelector,querySelectorAll, and the basics of working with element collections.
Practice what you just read.
Keep reading