Selecting elements

Find nodes with CSS selectors, understand when you get a static list versus a live collection, and scope a search to one subtree.

querySelector and querySelectorAll

const one  = document.querySelector('#signup .field');    // first match, or null
const many = document.querySelectorAll('ul.todos > li');  // static NodeList

many.forEach(el => el.classList.add('row'));   // a NodeList is iterable
const arr = Array.from(many);                  // convert when you need array methods
const n = document.querySelectorAll('.card').length;
  • Selectors are CSS syntax: .class, #id, [data-role="save"], li:not(.done).
  • querySelector returns the first match in document order, or null when nothing matches.
  • querySelectorAll always returns a NodeList, never a single element; index it or iterate it.
  • An invalid selector throws SyntaxError immediately, which is worth remembering when a selector string is assembled from a variable.

Static lists versus live collections

APIReturnsUpdates itself?
querySelectorAllstatic NodeListNo
getElementsByClassNamelive HTMLCollectionYes
getElementsByTagNamelive HTMLCollectionYes
document.forms, document.imageslive HTMLCollectionYes
element.childrenlive HTMLCollectionYes
const live = document.getElementsByClassName('task');   // suppose length is 3
list.append(newTask);
live.length;                    // 4 - the same object changed under you

// snapshot before mutating, or elements get skipped
for (const el of Array.from(live)) el.remove();

// same query, same result - only the liveness differs
document.querySelectorAll('.task').length;   // 3
⚠️
Removing nodes while looping a live collection skips matches, because the collection re-indexes on every mutation. Copy it first with Array.from(), or use querySelectorAll, which is already a snapshot.

Scoping a search and walking upwards

// search inside one subtree instead of the whole document
const card = document.querySelector('.card');
card.querySelector('.title');          // only nodes inside this card
card.querySelectorAll('a[href]');      // relative to the card

// walk up from the element you already have
const row  = event.target.closest('tr[data-id]');
const form = input.closest('form');
const outsideModal = el.closest('.modal') === null;

// test a single element without searching for it
if (link.matches('a[target="_blank"]')) { /* ... */ }
  • closest() starts at the element itself and walks up through its ancestors.
  • matches() answers a yes/no question about one element; it does not return a list.
  • Cache a parent reference and query inside it; a full-document query repeated inside a loop or a scroll handler is the usual performance mistake.
  • A selector called on an element is still matched against the whole subtree in tree order, so the result order is the document order of the matches.

FAQ

Why does my query return null even though the element exists?
The script ran before the parser reached the element, or the selector is wrong. Load the file with defer or as a module, and remember that ids are matched as #id rather than as bare text.
Is querySelectorAll slow?
A single call is fast enough to use freely. The cost appears when the same query runs on every keystroke or scroll event: cache the parent element and the result, and invalidate the cache only when the DOM actually changes.

Working with the DOM Reading and updating content safely

Last refreshed 2026-09-18.