JavaScript Selecting Elements
Before JavaScript can change anything on a web page, it first has to find the element it wants to change. Selecting elements means asking the browser’s Document Object Model (DOM) for a reference to one or more HTML nodes, based on their id, class, tag name, or a CSS selector. Once you hold that reference, you can read its content, change its style, listen for clicks on it, or remove it entirely. Almost every DOM script starts with a selection step, so understanding exactly what each selection method returns — and how it behaves when the page changes — is essential.
Overview / How it works
Every HTML page loaded in a browser is parsed into a tree of objects called the DOM. Each tag becomes a node, and the browser exposes that tree to JavaScript through the global document object. Selecting elements is simply the act of traversing or querying that tree to get back one or more node references.
The browser gives you two generations of selection APIs. The older methods — getElementById, getElementsByClassName, getElementsByTagName — were designed before CSS selectors were exposed to JavaScript, so each one only understands one specific criterion (an id, a class, or a tag). The newer methods — querySelector and querySelectorAll — accept any valid CSS selector string, so they can express arbitrarily complex conditions like '.card > .btn:not(.disabled)' in a single call.
A subtler but very important distinction is what kind of object each method returns. getElementById returns a single Element or null. getElementsByClassName and getElementsByTagName return a live HTMLCollection: a view onto the DOM that automatically updates itself whenever the document changes. querySelectorAll returns a static NodeList: a snapshot taken at the moment you called it, which will not change even if the DOM does. This live-vs-static behavior trips up almost every beginner at some point, so it gets its own section below.
Syntax
document.getElementById(id)
document.getElementsByClassName(classNames)
document.getElementsByTagName(tagName)
document.querySelector(cssSelector)
document.querySelectorAll(cssSelector)
- id — the value of an element’s
idattribute, without a leading#. Returns exactly oneElement, ornullif nothing matches. - classNames — one or more space-separated class names, without a leading
.. Returns a liveHTMLCollection. - tagName — an HTML tag name such as
'li'or'*'for all elements. Returns a liveHTMLCollection. - cssSelector — any valid CSS selector string (id, class, tag, attribute, combinator, pseudo-class, etc.).
querySelectorreturns the first matchingElementornull;querySelectorAllreturns a staticNodeListof every match, which may be empty.
All five methods also exist on any Element, not just document (except getElementById, which is document-only). Calling someElement.querySelectorAll('.item') searches only inside someElement‘s descendants.
Examples
Example 1: The basics — getElementById and querySelector
Assume the page contains <h1 id="title">Welcome</h1> and <p class="intro">Hello there</p>.
const heading = document.getElementById('title');
console.log(heading.textContent);
const intro = document.querySelector('.intro');
console.log(intro.textContent);
const missing = document.getElementById('does-not-exist');
console.log(missing);
Output:
Welcome
Hello there
null
getElementById is the fastest and most direct selector when you know an element’s unique id. querySelector('.intro') does the same job for a class, using a CSS selector string instead of a dedicated method. Notice that asking for an id that doesn’t exist returns null rather than throwing an error — always check for null before using the result.
Example 2: Live HTMLCollection vs static NodeList
Assume the page contains <ul id="list"><li class="item">One</li><li class="item">Two</li></ul>.
const liveItems = document.getElementsByClassName('item');
const staticItems = document.querySelectorAll('.item');
console.log(liveItems.length);
console.log(staticItems.length);
const newLi = document.createElement('li');
newLi.className = 'item';
newLi.textContent = 'Three';
document.getElementById('list').appendChild(newLi);
console.log(liveItems.length);
console.log(staticItems.length);
Output:
2
2
3
2
Both collections start with a length of 2, matching the two existing list items. After a third <li class="item"> is appended to the DOM, the live HTMLCollection (liveItems) automatically grows to 3, because it is a continuously-updated view of the document. The static NodeList (staticItems) stays at 2, because it was a frozen snapshot taken at the moment querySelectorAll ran. Neither behavior is “wrong” — you just need to know which one you’re using.
Example 3: A realistic selection + event wiring pattern
Assume the page contains three <div class="card" data-id="c1"> elements, each with one <button class="btn"> inside it.
function setupButtons() {
const buttons = document.querySelectorAll('.card .btn');
buttons.forEach((button) => {
button.addEventListener('click', (event) => {
const card = event.target.closest('.card');
console.log(`Clicked button in card: ${card.dataset.id}`);
});
});
console.log(`Wired up ${buttons.length} buttons`);
}
setupButtons();
Output:
Wired up 3 buttons
This is the pattern you’ll use constantly: select every matching element with querySelectorAll, then use NodeList.prototype.forEach to attach a listener to each one. Inside the handler, event.target.closest('.card') walks up from the clicked button to find its nearest ancestor matching .card, which is how you recover “which card was this” without storing extra state. Only the setup log fires here since no click occurs in this script; the click handler’s log would appear the moment a user actually clicked a button.
Under the hood
When you call a selection method, the browser doesn’t scan raw HTML text — it walks the already-parsed DOM tree, which is an in-memory tree of node objects connected by parentNode, childNodes, nextSibling, and similar pointers. getElementById is typically backed by an internal hash map keyed by id, making it essentially O(1). querySelector and querySelectorAll, by contrast, must parse your CSS selector string and then perform a tree search (often right-to-left, matching the rightmost part of the selector first and walking upward), which makes them more powerful but slower than the specialized methods for simple lookups.
The live-vs-static difference comes from what object is actually created. getElementsByClassName/getElementsByTagName return a special HTMLCollection object that holds a live query, re-evaluated against the DOM each time you access its length or iterate it. querySelectorAll instead executes the query once, immediately, and copies the matching references into a fixed-size NodeList — nothing you do to the DOM afterward touches that list.
Common Mistakes
Mistake 1: Including the CSS prefix in getElementById / getElementsByClassName. These older methods take a bare name, not a CSS selector.
Wrong: document.getElementById('#title') — this looks for an element whose id is literally the string "#title", which almost never exists, so it silently returns null.
const heading = document.getElementById('title');
Correct: pass just 'title', without the #. The # and . prefixes are only used inside CSS selector strings, i.e. with querySelector/querySelectorAll.
Mistake 2: Assuming querySelectorAll’s NodeList updates itself.
const items = document.querySelectorAll('.item');
// ...later, code adds new .item elements to the page...
console.log(items.length); // still the OLD count, not the new one
Because querySelectorAll returns a static snapshot, code that expects the list to grow as elements are added will silently work with stale data. Corrected: either re-run querySelectorAll('.item') after the DOM changes, or use getElementsByClassName('item') if you specifically want a live view.
Mistake 3: Calling array methods directly on an HTMLCollection.
const items = document.getElementsByClassName('item');
// items.forEach(...) would throw: items.forEach is not a function
An HTMLCollection is array-like (it has a length and indexed access) but is not a real array and lacks methods like forEach, map, or filter. A NodeList from querySelectorAll does have forEach, but still lacks map/filter. Convert either one first: Array.from(items).forEach(...) or [...items].map(...).
Best Practices
- Reach for
querySelector/querySelectorAllby default — they cover every case the older methods do and let you express complex conditions with familiar CSS syntax. - Use
getElementByIdwhen you specifically have an id and care about raw lookup speed, such as in a hot loop. - Always check single-element results for
nullbefore using them, since a typo’d selector fails silently rather than throwing. - Remember that
querySelectorAllresults are static; re-query after DOM mutations if you need fresh matches. - Cache a selection in a variable if you’ll use it more than once, instead of re-querying the DOM repeatedly — DOM queries are far slower than reading a local variable.
- Scope your search to a container element (
container.querySelectorAll(...)) instead of the wholedocumentwhenever possible, to avoid accidentally matching unrelated elements elsewhere on the page. - Convert array-like results with
Array.from(list)or the spread operator[...list]when you need array methods likemaporfilter.
Practice Exercises
Exercise 1: Given a page with <div id="app"></div>, write code that selects it with getElementById and sets its textContent to 'Ready'.
Exercise 2: Given several <li class="task"> elements, use querySelectorAll to select them all, convert the result to a real array, and log an array of just their textContent values using map.
Exercise 3: Explain (in your own words, or with a small script and comments) why storing the result of getElementsByClassName('task') in a variable named taskCount and reading taskCount.length later can give a surprising answer after tasks are added or removed. Then rewrite it using querySelectorAll so the count is a fixed snapshot.
Summary
getElementById(id)returns one element ornull, using a bare id with no#.getElementsByClassNameandgetElementsByTagNamereturn a liveHTMLCollectionthat automatically reflects DOM changes.querySelectorandquerySelectorAllaccept any CSS selector and are the most flexible, modern choice.querySelectorAllreturns a staticNodeList, frozen at query time, though it does supportforEach.- Neither
HTMLCollectionnorNodeListis a true array; useArray.from()or the spread operator to get array methods likemap/filter. - Always guard against
nullresults from single-element selectors before using them.
