DOM Traversal with querySelector
querySelector, querySelectorAll, closest, and matches — CSS selectors in JS, NodeList vs HTMLCollection, and scoping roots.
- javascript
- querySelector
- dom
- selectors
getElementById is fine. querySelector / querySelectorAll are the everyday tools because they speak CSS selectors and work from any element root, not only document. Pair them with closest and matches and you can do event delegation without a framework.
querySelector vs querySelectorAll
const main = document.querySelector('main'); // first match or null
const items = document.querySelectorAll('.item'); // static NodeList
main?.querySelector('.title'); // scoped search
// invalid selector throws
// document.querySelector('div[')
querySelector |
querySelectorAll |
|
|---|---|---|
| Result | Element or null |
NodeList (static) |
| Count | first in tree order | all |
items.forEach((el) => el.classList.add('ready'));
[...items].map((el) => el.id);
// NodeList is array-like; convert when you need map/filter
Static NodeList from querySelectorAll does not auto-update when the DOM changes. Live collections like getElementsByClassName do — another footgun.
Useful selectors
document.querySelector('#app');
document.querySelector('[data-id="42"]');
document.querySelector('input[name="email"]');
document.querySelector('.modal:not(.hidden)');
document.querySelector('ul > li:first-child');
document.querySelectorAll('button[type="submit"]');
Scope to a root to avoid matching the whole page:
function $(sel, root = document) {
return root.querySelector(sel);
}
function $$(sel, root = document) {
return [...root.querySelectorAll(sel)];
}
matches and closest
el.matches('.item.active'); // is this element matching?
// walk ancestors (including self)
const row = event.target.closest('tr[data-id]');
if (!row) return;
const id = row.dataset.id;
closest is the backbone of event delegation:
document.querySelector('#table').addEventListener('click', (e) => {
const btn = e.target.closest('button.delete');
if (!btn || !e.currentTarget.contains(btn)) return;
deleteRow(btn.closest('tr'));
});
Tree walkers (when selectors aren’t enough)
el.parentElement;
el.children; // element children only
el.childNodes; // includes text
el.nextElementSibling;
el.previousElementSibling;
el.firstElementChild;
Prefer element-* properties when you don’t want text nodes.
Performance notes
getElementByIdis fastest for a single known id.- Complex selectors on huge documents cost more — scope the root.
- Don’t call
querySelectorAllinside tight animation loops; cache nodes. - Avoid
*universal selector over large subtrees.
Footguns
- Forgetting null checks on
querySelector. - Assuming NodeList is live.
:scopewhen using selectors relative to an element in some APIs.- Shadow DOM — open shadow roots need
shadowRoot.querySelector; closed roots block you. - Invalid characters in IDs — CSS.escape for dynamic selectors:
document.querySelector(`#${CSS.escape(dynamicId)}`);
Interview answer
“querySelector returns the first match or null; querySelectorAll returns a static NodeList. I scope queries from a root element, use closest/matches for delegation, and CSS.escape for dynamic IDs. Live HTMLCollections from getElementsBy* differ from static NodeLists.”
Related
querySelectorAll vs children loops
// all descendants
const cells = table.querySelectorAll('td.selected');
// only element children
const items = [...list.children].filter((el) => el.matches('.item'));
// one hop parent check
if (el.parentElement?.matches('.row')) {
/* ... */
}
closest walks up through ancestors; it does not search sideways. For “nearest previous sibling matching X”, use a loop on previousElementSibling. In Shadow DOM, closest stops at the shadow root — use event.composedPath() when handling events that crossed the boundary.
Caching query results
// query once; reuse through a UI interaction
const dialog = document.querySelector('#confirm');
const confirmBtn = dialog.querySelector('[data-action=ok]');
confirmBtn.addEventListener('click', onConfirm);
Repeated document.querySelector in hot paths (input handlers, rAF) is usually wasted work. Cache element references when the node is stable; re-query when the subtree is replaced. For dynamic lists, prefer delegation on a stable parent over binding each row after every render.
Further reading
Related guides
- classList and datasetToggle CSS classes with classList and read data-* attributes via dataset — tokens, naming, and DOM performance notes.
- client, offset, and scroll DimensionsclientWidth vs offsetWidth vs scrollWidth — borders, scrollbars, and which box measurement to use for layout math.
- contentEditable PitfallsWhy contentEditable is hard: browser HTML mess, carets, sanitization, undo, and when to pick a real editor framework.
- Creating and Updating DOM NodescreateElement, textContent vs innerHTML, insert APIs, and batching updates — safe DOM writes without accidental XSS.
- Custom EventsCustomEvent, detail payloads, bubbles and composed — decoupling components without a global event bus mess.