querySelector and querySelectorAll are how you select DOM elements in JavaScript: hand them a CSS selector and get back the first match or every match. This page covers what each one returns, how to scope a search to one panel instead of the whole document, how to walk up to an ancestor element, and the two mistakes that fail silently instead of loudly.
Why querySelector became the default way to pick elements
The DOM started with a narrow vocabulary: getElementById, getElementsByTagName, getElementsByClassName. Each one is a special-purpose lookup, and none of them can express a relationship. There was no way to ask for "the second a inside every li that has the class active", and browser support for the list varied enough that early libraries existed just to paper over it.
querySelector replaced that pile with a single method that speaks the CSS selector syntax you already use in stylesheets. One API covers ids, classes, attributes, descendant combinators, and pseudo-classes such as :checked or :nth-child. Because it is built on the same selector engine as CSS, there is no second dialect to learn, and a selector that works in your stylesheet works here too.
Here is the page these examples run against. Notice that the structure is deliberately ordinary, because the point is the selection code rather than the markup:
<nav class="sidebar" id="sidebar">
<ul id="menu">
<li class="nav-item"><a href="#home" data-id="home">Home</a></li>
<li class="nav-item"><a href="#reports" data-id="reports">Reports</a></li>
<li class="nav-item"><a href="#settings" data-id="settings">Settings</a></li>
</ul>
</nav>
<main id="main">
<h1 id="page-title">Dashboard</h1>
<section id="panel-reports" class="panel">
<h2>Weekly reports</h2>
<table><tbody>
<tr><td>Mon</td><td>42</td></tr>
<tr><td>Tue</td><td>57</td></tr>
</tbody></table>
</section>
<section id="panel-settings" class="panel">
<h2>Settings</h2>
<p class="muted">Nothing to configure yet.</p>
</section>
</main>
What does querySelector return compared with querySelectorAll?
querySelector returns a single element: the first match in document order, or null when nothing matches. querySelectorAll returns a NodeList holding every match, in document order, and an empty NodeList when nothing matches.
That difference decides how you write the surrounding code. A single result is used directly, and every use of it needs a null check. A list result is looped over, so an empty list is harmless and no guard is needed. Mixing the two up is the source of the classic Cannot read properties of null crash: you called querySelector, stored the result, and then dereferenced it without checking.
// querySelector gives you the FIRST match, or null
const firstLink = document.querySelector(".nav-item a");
// querySelectorAll gives you EVERY match as a NodeList
const allLinks = document.querySelectorAll(".nav-item a");
console.log(firstLink.textContent); // "Home"
console.log(allLinks.length); // 3
console.log(firstLink.getAttribute("href")); // "#home"
// A NodeList is array-LIKE, not an Array
console.log(typeof allLinks.map); // "undefined"
console.log(typeof allLinks.forEach); // "function"
The last two lines matter more than they look. A NodeList supports length, index access, forEach and for...of, but not map, filter or reduce. When you want those, convert it first with the spread operator: [...allLinks].map(...). Spreading also gives you a real array you can sort or copy.
How do I search inside one element instead of the whole page?
Every element has its own querySelector and querySelectorAll methods, and they only look at that element's descendants. panel.querySelector("h2") finds the first heading inside the panel; it will never reach for a heading elsewhere on the page. Scoping like this keeps a component self-contained, and it is cheaper, because the browser can skip the parts of the tree that are not under the element you started from.
const panel = document.querySelector("#panel-reports");
const heading = panel.querySelector("h2");
const firstCell = panel.querySelector("tbody tr td");
const rows = panel.querySelectorAll("tbody tr");
console.log(heading.textContent); // "Weekly reports"
console.log(firstCell.textContent); // "Mon"
console.log(rows.length); // 2
console.log(panel.querySelectorAll("h2").length); // 1
Two details catch people out here. First, the scoped search still walks descendants, so panel.querySelector("#menu") finds nothing even though #menu exists on the page, because #menu is not inside the panel. Second, scoping does not change the meaning of a selector: tbody tr td means a cell inside a row inside a table body, and the element you scope to must be an ancestor of the whole chain for it to match.
How do I find the element a click actually landed on?
When you listen for events on a container, the object handed to your callback is the element the event physically struck, which might be a span inside a button inside a list row. What you usually want is the row. closest answers exactly that question: given a selector, it walks up from the starting element, one ancestor at a time, and returns the first one that matches. It checks the element itself before moving upward, so el.closest("li") on an li returns that same li.
Pair closest with the dataset object to read the identifying details off the matched element. Every data-* attribute becomes a camelCase key, so data-id="reports" is read as element.dataset.id.
const menu = document.querySelector("#menu");
const target = menu.querySelector('[data-id="reports"]');
const listItem = target.closest("li");
const sidebar = target.closest(".sidebar");
console.log(target.dataset.id); // "reports"
console.log(listItem.tagName); // "LI"
console.log(listItem.className); // "nav-item"
console.log(sidebar.id); // "sidebar"
Should I still use getElementById?
There is nothing wrong with it. getElementById is fast, widely supported, and returns the element or null, so it behaves the same way as querySelector("#some-id"). The only difference is that one is a single-purpose lookup and the other accepts any CSS selector, which means you can move from an id to a class or an attribute selector without changing method.
The methods worth retiring are the plural ones that return an HTMLCollection. Unlike the NodeList from querySelectorAll, an HTMLCollection is live: it updates itself as the document changes, so a collection you stored earlier may now hold different elements than it did when you captured it. That behaviour is convenient right up until it silently invalidates a loop you are in the middle of.
| Call | Returns | Live? |
|---|---|---|
querySelector(sel) | First element or null | No |
querySelectorAll(sel) | Static NodeList | No |
getElementById(id) | Element or null | No |
getElementsByClassName(c) | HTMLCollection | Yes |
getElementsByTagName(t) | HTMLCollection | Yes |
Which selector mistakes break silently?
The first silent failure is a stale collection. A NodeList is a snapshot taken at the moment you called querySelectorAll. Add a new matching element afterwards and the snapshot does not grow, which quietly breaks code that assumed the list tracks the page.
const menu = document.querySelector("#menu");
const items = menu.querySelectorAll(".nav-item");
const extra = document.createElement("li");
extra.className = "nav-item";
extra.textContent = "Audit";
menu.append(extra);
console.log(items.length); // 3 — a snapshot, not a live view
console.log(menu.querySelectorAll(".nav-item").length); // 4 — a fresh query sees the new row
The second silent failure is a selector that matches nothing when you expected a match, usually a spacing mistake. .nav-item a means "an a inside something with the class nav-item". .nav-item.a means "one element that has both classes". Confusing them yields null rather than an error, and the null becomes a crash two lines later, far from the cause.
A genuinely invalid selector, on the other hand, throws a SyntaxError naming the offending text, which is far kinder. If you get a null where you expected an element, print the selector and check it character by character before you start debugging the logic around it.
const missing = document.querySelector(".does-not-exist");
console.log(missing); // null
console.log(missing?.textContent); // undefined — optional chaining avoids the crash
console.log(document.querySelectorAll(".does-not-exist").length); // 0
The selection calls worth memorising
document.querySelector(sel)— first match in the whole document, or null.document.querySelectorAll(sel)— a static NodeList of every match.element.querySelector(sel)— same thing, restricted to that element's descendants.element.closest(sel)— walk up from an element to the nearest ancestor matching the selector, including the element itself.element.dataset.name— read adata-nameattribute as camelCase.[...nodeList]— turn a NodeList into a real array when you needmaporfilter.
Try it yourself: open any page you like, open the browser console, and run document.querySelectorAll("a"), then document.querySelectorAll("a").length. Now add console.log(document.querySelector("main")?.id) and change the selector to a tag that does not exist. Next, pick one of the links you found, store it, and print link.closest("nav")?.tagName to see how far up the tree the match sits.