JAVASCRIPT / THE BROWSER: DOM, EVENTS, AND STORAGE
Selecting elements with query selectors
Find any element on a page with CSS selectors, scope searches to a subtree, and handle NodeLists and null results correctly.
What you will learn
- querySelector returns the first match or null; querySelectorAll returns every match
- Scope a search to a subtree with element.querySelectorAll and anchor it with :scope
- Treat a NodeList as a static snapshot; spread it into an array to map or filter
- Check a single element against a selector with matches() instead of re-querying
Understanding Selecting elements with query selectors
`document.querySelector` and `document.querySelectorAll` take one string and hand it to the same selector engine the browser uses to match CSS rules. That is why `#cart .item`, `.item.sale`, `input[type="email"]` and `li:not(.done)` all work unchanged: if a stylesheet rule could target an element, a query can find it. `querySelector` stops at the first element in document order and gives back `null` when nothing matches; `querySelectorAll` keeps going and returns every match.
Both methods also exist on every element, and `container.querySelectorAll('p')` looks only inside `container`. The rule behind that is worth knowing because it surprises people: the selector is still evaluated against the whole document, and only afterwards are the results filtered to nodes that sit inside the element you called it on. So `inner.querySelector('#outer p')` can succeed even when `#outer` is an ancestor of `inner`, and an element is never included in its own results. When you really mean "direct children of this element", anchor the selector with `:scope`, as in `:scope > li`.
What `querySelectorAll` returns is a NodeList: an ordered, array-like snapshot taken at the instant the query ran. Adding a matching element later does not change it, so you re-run the query; that is the opposite of `getElementsByClassName`, whose HTMLCollection is live and keeps itself in sync with the document. A NodeList supports `length`, index access and `forEach`, but not `map`, `filter` or `find`, so spread it with `[...list]` or use `Array.from` when you need real array methods.
For a single element you already have, `element.matches(selector)` answers yes or no without searching anything, which is how you filter a set you obtained some other way.
document.body.innerHTML = `
<ul id="cart">
<li class="item" data-sku="A1">Keyboard <span class="price">49</span></li>
<li class="item sale" data-sku="B2">Mouse <span class="price">19</span></li>
<li class="item" data-sku="C3">Monitor <span class="price">199</span></li>
</ul>`;
const first = document.querySelector('#cart .item');
console.log('first:', first.getAttribute('data-sku'));
const salePrice = document.querySelector('.item.sale .price');
console.log('sale price:', salePrice.textContent);
const items = document.querySelectorAll('#cart .item');
console.log('count:', items.length, 'type:', items.constructor.name);
console.log('skus:', [...items].map(li => li.getAttribute('data-sku')).join(','));
console.log('checkout:', document.querySelector('#checkout'));A query selector hands a CSS selector to the browser's own matching engine and returns a snapshot of what matched at that moment, not a live view of the page.
Worked examples
Static NodeList versus live HTMLCollection
Shows that a NodeList from querySelectorAll is frozen while getElementsByClassName keeps updating.
document.body.innerHTML = '<div id="box"><p class="note">one</p><p class="note">two</p></div>';
const box = document.querySelector('#box');
const staticList = document.querySelectorAll('.note');
const liveList = document.getElementsByClassName('note');
console.log('before:', staticList.length, liveList.length);
const extra = document.createElement('p');
extra.className = 'note';
box.append(extra);
console.log('after:', staticList.length, liveList.length);
console.log('fresh:', document.querySelectorAll('.note').length);Example explained
Line 1`document.querySelectorAll('.note')` builds its list once, so `staticList` stays at two nodes forever.
Line 2`getElementsByClassName('note')` returns a live HTMLCollection that is recomputed as you read it.
Line 3After `box.append(extra)` only `liveList` reports 3, because the snapshot cannot know about the new node.
Line 4Running the query again is the fix: `fresh` reflects the current document.
Scoped queries and :scope
Demonstrates that an element-level query filters document-wide matches instead of restarting the search at that element.
document.body.innerHTML = `
<section id="outer">
<p>a</p>
<div id="inner"><p>b</p></div>
</section>`;
const outer = document.querySelector('#outer');
const inner = outer.querySelector('#inner');
console.log('all:', [...outer.querySelectorAll('p')].map(p => p.textContent).join('|'));
console.log('direct:', [...outer.querySelectorAll(':scope > p')].map(p => p.textContent).join('|'));
console.log('from inner:', inner.querySelectorAll('#outer p').length);Example explained
Line 1`outer.querySelectorAll('p')` matches descendants at any depth, so the nested `b` is included.
Line 2`:scope > p` makes `outer` the reference element, so only its direct child `a` qualifies.
Line 3`inner.querySelectorAll('#outer p')` still finds `b`: the selector is matched against the document, then results outside `inner` are discarded.
Line 4Nothing in a scoped result can ever be the element itself, only nodes below it.
Attribute selectors and matches()
Selects form fields by attribute and tests individual elements against a selector.
document.body.innerHTML = `
<form>
<input name="email" type="email" required>
<input name="nick" type="text">
<input name="age" type="number" required>
</form>`;
const fields = document.querySelectorAll('form input');
const names = [...fields].filter(el => el.matches('[required]')).map(el => el.name);
console.log('required:', names.join(','));
console.log('union:', document.querySelectorAll('input[type="number"], input[type="email"]').length);
console.log('first optional:', document.querySelector('input:not([required])').name);Example explained
Line 1`[...fields]` converts the NodeList into an array so `filter` and `map` become available.
Line 2`el.matches('[required]')` tests one element and returns a boolean; it searches nothing.
Line 3A comma in a selector is a union, so the two `input[type=...]` clauses together match 2 elements.
Line 4`input:not([required])` returns nick because querySelector always picks the earliest match in document order.
Important notes
querySelectorAll never returns null. A failed search yields a NodeList of length 0, which is truthy, so test list.length rather than the list itself.
Pseudo-elements such as ::before can never be selected because they are not nodes, and state pseudo-classes like :hover or :checked describe only the moment the query ran.
Common mistakes
Leaving out the . or #: querySelector('item') searches for an <item> tag, returns null, and the next line throws "Cannot read properties of null".
Calling .map directly on querySelectorAll's result, which throws "items.map is not a function" because a NodeList is array-like but not an array.
Building a selector by concatenation, as in document.querySelector('#' + id): if the id starts with a digit or contains a space or dot the string is invalid CSS and the call throws a SyntaxError, so use [id="..."] or CSS.escape instead.
Try it yourself
Change, predict, then run
On a blank page, set document.body.innerHTML to a <ul> of five <li> tasks carrying data-done="true" or data-done="false", then log how many are done using a single attribute selector and log the text of the first unfinished one.
Open the JavaScript workspaceCheck your understanding
You save const rows = document.querySelectorAll('#list li'), then append two more <li> elements to #list. rows.length is unchanged. What explains this?
- The appended items do not match '#list li' because a script created them
- A NodeList refreshes its length only after you iterate it with forEach
- querySelectorAll evaluated the selector once and handed back a static snapshot
- querySelectorAll ignores elements inserted after the page finished loading
Show answer
querySelectorAll runs the match once and fills a fixed NodeList, so later insertions are invisible to it; running the query again returns four rows, and getElementsByTagName('li') would have grown on its own because HTMLCollections are live. The first option is tempting but wrong: the new elements do match the selector, which the fresh query proves, and how a node was created has no effect on selector matching.