Selecting Elements
Before you can read text, change styles, or attach event listeners, you have to find the right element in the page. The DOM gives you two families of methods for this: the older getElementById / getElementsByClassName style, and the modern, far more flexible querySelector / querySelectorAll style that accepts any CSS selector. This page explains how each works, the crucial difference between live and static collections, and how to turn the results into real arrays you can map and filter over.
The getElement* family
These methods predate CSS-selector queries. Each one looks up elements by a single, specific criterion, and their names tell you exactly what they match.
const header = document.getElementById("main-header"); // one element or null
const items = document.getElementsByClassName("list-item"); // HTMLCollection
const inputs = document.getElementsByTagName("input"); // HTMLCollection
getElementById is the fastest lookup in the DOM and returns a single element (or null if nothing matches). The plural methods return an HTMLCollection — an array-like object that is live, meaning it updates automatically as the document changes. Note that getElementById only exists on document, while the others can be called on any element to scope the search to that subtree.
Element IDs must be unique on a page. If two elements share an ID,
getElementByIdreturns the first one and your code becomes unpredictable — prefer classes or attributes for groups.
querySelector and querySelectorAll
querySelector and querySelectorAll accept any CSS selector string, so you can express complex queries that the getElement* methods simply cannot. querySelector returns the first match (or null); querySelectorAll returns all matches as a NodeList.
const firstError = document.querySelector(".form .error"); // first match or null
const allLinks = document.querySelectorAll("nav a[href^='/']"); // every match
const row = table.querySelector("tr:nth-child(2) td"); // scoped to `table`
Because they take full selectors, these two methods cover descendant combinators, attribute selectors, pseudo-classes, and grouping — all in a single readable call.
// Grab every checked checkbox inside the settings panel
const checked = document.querySelectorAll("#settings input[type='checkbox']:checked");
console.log(checked.length);
Output:
3
Live vs static collections
This is the difference that trips people up. getElementsByClassName and getElementsByTagName return a live HTMLCollection that reflects the DOM in real time. querySelectorAll returns a static NodeList — a snapshot taken at the moment you called it that never changes afterward.
const live = document.getElementsByClassName("box"); // live HTMLCollection
const frozen = document.querySelectorAll(".box"); // static NodeList
document.body.append(document.createElement("div")).className = "box";
console.log(live.length); // increased — it saw the new element
console.log(frozen.length); // unchanged — it's a snapshot
Output:
4
3
The live behavior is a classic source of infinite loops: iterating a live collection while adding matching elements inside the loop keeps growing it forever. Static NodeLists avoid that, which is one reason querySelectorAll is the safer default.
| Method | Returns | Selector? | Live or static | On any element? |
|---|---|---|---|---|
getElementById | Element | null | ID only | n/a | No (document only) |
getElementsByClassName | HTMLCollection | class only | Live | Yes |
getElementsByTagName | HTMLCollection | tag only | Live | Yes |
querySelector | Element | null | Any CSS | n/a | Yes |
querySelectorAll | NodeList | Any CSS | Static | Yes |
Converting NodeLists to arrays
A NodeList has forEach, but it lacks map, filter, reduce, and the rest of the array toolkit. An HTMLCollection doesn’t even have forEach. To use array methods, convert the collection first with Array.from() or the spread operator.
const nodes = document.querySelectorAll("li");
const texts = Array.from(nodes, (li) => li.textContent.trim()); // map while converting
const spread = [...document.getElementsByClassName("tag")]; // HTMLCollection → array
const active = [...nodes].filter((li) => li.classList.contains("active"));
console.log(texts, active.length);
Array.from is especially handy because its optional second argument is a mapping function, so you convert and transform in one pass.
A runnable demo
The pen below selects elements both ways and shows the live/static difference when you add a new item.
<ul id="list">
<li class="item">Apples</li>
<li class="item">Bananas</li>
<li class="item">Cherries</li>
</ul>
<button id="add">Add item</button>
<p id="out"></p>
<script>
const live = document.getElementsByClassName("item"); // live
const frozen = document.querySelectorAll(".item"); // static snapshot
const out = document.querySelector("#out");
function render() {
out.textContent = `live = ${live.length}, static = ${frozen.length}`;
}
document.querySelector("#add").addEventListener("click", () => {
const li = document.createElement("li");
li.className = "item";
li.textContent = `Item ${live.length + 1}`;
document.querySelector("#list").append(li);
render(); // live grows, static stays at 3
});
render();
</script>
Best Practices
- Reach for
querySelector/querySelectorAllby default — one consistent, selector-based API covers nearly every case. - Use
getElementByIdwhen you have a known unique ID and want the fastest possible lookup. - Always handle the
nullreturn fromquerySelectorandgetElementByIdbefore touching properties. - Convert collections with
Array.from()or[...collection]before usingmap,filter, orreduce. - Remember
querySelectorAllis static andgetElementsBy*is live; don’t mutate the DOM while iterating a live collection. - Scope queries to a container element (
container.querySelectorAll(...)) instead of always searching fromdocumentfor clearer, faster code. - Quote attribute values inside selectors (
input[type='text']) to stay valid across all selector engines.