ESC

Type to search the knowledge base.

DOM Traversal with querySelector

querySelector, querySelectorAll, closest, and matches — CSS selectors in JS, NodeList vs HTMLCollection, and scoping roots.

beginner3 min read
  • 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

  • getElementById is fastest for a single known id.
  • Complex selectors on huge documents cost more — scope the root.
  • Don’t call querySelectorAll inside tight animation loops; cache nodes.
  • Avoid * universal selector over large subtrees.

Footguns

  1. Forgetting null checks on querySelector.
  2. Assuming NodeList is live.
  3. :scope when using selectors relative to an element in some APIs.
  4. Shadow DOM — open shadow roots need shadowRoot.querySelector; closed roots block you.
  5. 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.”

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