In plain words
The DOM is a family tree. The document is the ancestor of everyone. <html> is its child, <head> and <body> are siblings, and so on down to the smallest <span>. Every member of the tree is a node. Before JavaScript can change anything, it must find the right node: this lesson is about asking the tree precise questions such as “give me every product that is out of stock” or “which order row contains the button that was clicked?”.
Why it matters
Selecting is the first line of almost every DOM feature: a search box selects the input and the result list; a cart selects the counter badge; a table selects the row whose Delete button was pressed. Getting it wrong gives you the classic TypeError: Cannot read properties of null, highlights the wrong item, or leaves a counter showing stale numbers because you held on to an old list. Getting it right means short, robust code that keeps working when a designer adds a wrapper <div>.
How it works
Nodes, elements and node types
Every node has a nodeType number and a nodeName. You will mostly meet four kinds:
| nodeType | Constant | Example | nodeName |
|---|---|---|---|
| 1 | Node.ELEMENT_NODE | <li> | LI (upper case in HTML) |
| 3 | Node.TEXT_NODE | The text “Pen”, or a new line between tags | #text |
| 8 | Node.COMMENT_NODE | <!-- note --> | #comment |
| 9 | Node.DOCUMENT_NODE | document | #document |
The surprise for beginners is whitespace. The line break and indentation between two tags in your HTML is text, so it becomes a text node. That is why there are two families of properties: node properties that include text and comments, and element properties that skip them.
Try it: Nodes are not all elements
JavaScript
const card = document.createElement('article');
card.innerHTML = '<h2>Desk lamp</h2>\n <!-- price from API -->\n <p>₹1,299</p>';
console.log('childNodes:', card.childNodes.length, '| children:', card.children.length);
for (const node of card.childNodes) {
console.log(node.nodeType, node.nodeName, JSON.stringify(node.textContent));
}
console.log('firstChild:', card.firstChild.nodeName);
console.log('firstElementChild:', card.firstElementChild.nodeName);
console.log('lastElementChild.previousSibling:', card.lastElementChild.previousSibling.nodeName);
console.log(Node.ELEMENT_NODE, Node.TEXT_NODE, Node.COMMENT_NODE, Node.DOCUMENT_NODE);
The whitespace and the comment in the HTML string become nodes too. children skips them; childNodes does not.
| Includes text and comment nodes | Elements only (use these) |
|---|---|
childNodes | children |
firstChild / lastChild | firstElementChild / lastElementChild |
nextSibling / previousSibling | nextElementSibling / previousElementSibling |
parentNode | parentElement |
In the DOM classes, an element is a special kind of node: HTMLLIElement extends HTMLElement, which extends Element, which extends Node. That prototype chain is why every element has both childNodes (from Node) and children (from Element).
Selecting with CSS selectors
Four methods cover nearly everything:
// One element or null. Any CSS selector.
const searchBox = document.querySelector('#search');
const firstSale = document.querySelector('.product .price.sale');
// All matches, as a static NodeList (possibly empty, never null).
const soldOut = document.querySelectorAll('.product[data-stock="0"]');
// Fast path for ids: no #, only on document.
const cart = document.getElementById('cart');
// Search inside one element instead of the whole page.
const list = document.getElementById('list');
const featured = list.querySelectorAll('.featured');
Anything you can write in a stylesheet works: attribute selectors, :not(), :nth-child(), even :has() (“a product that has a sale price”), which is supported in all current major browsers (check the MDN compatibility table if you support older ones). An invalid selector throws a SyntaxError DOMException, so wrap user-typed selectors in try/catch. Try it on the Ralfiz Store inventory:
Try it: Selector playground: query and highlight products
HTML
<main class="shop">
<header class="bar">
<div>
<h1>Ralfiz Store · Inventory</h1>
<p class="muted">Type a CSS selector. Matching products light up.</p>
</div>
<span class="pill" id="count">0 matches</span>
</header>
<form class="query" id="query">
<input id="selector" value='.product[data-stock="0"]' spellcheck="false" autocomplete="off" aria-label="CSS selector">
<button>Run</button>
</form>
<div class="examples" id="examples">
<button type="button">.product</button>
<button type="button">[data-category="audio"]</button>
<button type="button">.product:nth-child(odd)</button>
<button type="button">.product:has(.sale)</button>
<button type="button">.product:not(.featured)</button>
<button type="button">li > .price</button>
</div>
<p class="error" id="error" hidden></p>
<ul class="list" id="list">
<li class="product featured" data-category="audio" data-stock="12"><span class="thumb">🎧</span><div class="info"><h2>Wave Pro Headphones</h2><small>audio · 12 in stock</small></div><span class="price">₹4,999</span></li>
<li class="product" data-category="audio" data-stock="0"><span class="thumb">🔊</span><div class="info"><h2>Pulse Mini Speaker</h2><small>audio · out of stock</small></div><span class="price">₹1,799</span></li>
<li class="product" data-category="desk" data-stock="31"><span class="thumb">💡</span><div class="info"><h2>Glow Desk Lamp</h2><small>desk · 31 in stock</small></div><span class="price sale">₹1,299</span></li>
<li class="product featured" data-category="desk" data-stock="5"><span class="thumb">⌨️</span><div class="info"><h2>Tactile Keyboard</h2><small>desk · 5 in stock</small></div><span class="price">₹3,450</span></li>
<li class="product" data-category="bags" data-stock="0"><span class="thumb">🎒</span><div class="info"><h2>Commuter Backpack</h2><small>bags · out of stock</small></div><span class="price sale">₹2,199</span></li>
</ul>
</main>
CSS
:root { --accent: #0f766e; --line: #e4e7ec; --muted: #667085; }
body { margin: 0; background: #f5f7f8; color: #1d2939; }
.shop { padding: 16px; display: grid; gap: 12px; }
.bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
h1 { font-size: 18px; margin: 0; }
.muted { margin: 2px 0 0; font-size: 13px; color: var(--muted); }
.pill { font-size: 12px; font-weight: 700; padding: 4px 10px; border-radius: 999px; background: #ccfbf1; color: #115e59; }
.query { display: flex; gap: 8px; }
.query input { flex: 1; min-width: 0; font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #fff; color: inherit; }
.query input:focus { outline: none; border-color: #2dd4bf; box-shadow: 0 0 0 3px #ccfbf1; }
button { font: inherit; font-weight: 600; font-size: 14px; border-radius: 10px; cursor: pointer; }
.query button { padding: 0 16px; border: 0; background: var(--accent); color: #fff; }
.query button:hover { background: #115e59; }
.examples { display: flex; flex-wrap: wrap; gap: 6px; }
.examples button { font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; padding: 4px 8px;
border: 1px solid var(--line); background: #fff; color: #344054; }
.examples button:hover { border-color: #2dd4bf; color: var(--accent); }
.error { margin: 0; font-size: 13px; color: #b42318; background: #fef3f2; border: 1px solid #fecdca;
padding: 8px 12px; border-radius: 8px; }
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.product { display: flex; align-items: center; gap: 12px; padding: 10px 12px; background: #fff;
border: 1px solid var(--line); border-radius: 12px; transition: all .15s; }
.thumb { width: 40px; height: 40px; border-radius: 10px; display: grid; place-items: center; background: #f2f4f7; font-size: 20px; }
.info { flex: 1; min-width: 0; }
.info h2 { font-size: 14px; margin: 0; }
.info small { color: var(--muted); }
.price { font-weight: 700; font-size: 14px; }
.price.sale { color: #c2410c; }
.product.hit { border-color: var(--accent); background: #f0fdfa; box-shadow: 0 0 0 3px #99f6e4; }
JavaScript
const list = document.getElementById('list');
const input = document.querySelector('#selector');
const count = document.querySelector('#count');
const error = document.querySelector('#error');
function highlight(selector) {
list.querySelectorAll('.hit').forEach((el) => el.classList.remove('hit'));
error.hidden = true;
let matches;
try {
matches = list.querySelectorAll(selector);
} catch (err) {
error.textContent = err.name + ': "' + selector + '" is not a valid selector.';
error.hidden = false;
count.textContent = 'error';
return;
}
// Highlight the whole product card, even when the match is a child.
matches.forEach((el) => el.closest('.product')?.classList.add('hit'));
count.textContent = matches.length + (matches.length === 1 ? ' match' : ' matches');
console.log(selector, '→', matches.length, 'match(es)');
}
document.querySelector('#query').addEventListener('submit', (event) => {
event.preventDefault();
highlight(input.value);
});
document.querySelector('#examples').addEventListener('click', (event) => {
const btn = event.target.closest('button');
if (!btn) return;
input.value = btn.textContent;
highlight(input.value);
});
highlight(input.value);
Type any CSS selector, or click an example. Invalid selectors throw a SyntaxError, which the page catches and shows.
Notice one subtle rule. element.querySelectorAll('ul li') finds descendants of element, but the whole selector is matched against the full document. So list.querySelectorAll('ul li') matches, even though the ul is list itself, and a selector like '.page li' matches if .page is an ancestor outside the element. When you want “direct children of this element”, write :scope > li.
Static NodeList vs live HTMLCollection
Lists come in two flavours, and mixing them up causes stale-data bugs:
| Returned by | Type | Updates when the DOM changes? | forEach? |
|---|---|---|---|
querySelectorAll | NodeList | No, a snapshot | Yes |
childNodes | NodeList | Yes, live | Yes |
children, getElementsByClassName, getElementsByTagName | HTMLCollection | Yes, live | No |
Both are array-like and iterable, so for...of works on both, and Array.from(list) or [...list] gives you a real array with map and filter.
Try it: Static NodeList vs live HTMLCollection
HTML
<main class="panel">
<header><h1>🛒 Cart</h1><span class="muted">Two lists, taken once at start-up</span></header>
<ul id="cart" class="cart">
<li class="item">Notebook A5</li>
<li class="item">Gel pen, blue</li>
</ul>
<div class="stats">
<div class="stat"><code>querySelectorAll('.item')</code><strong id="static-count">2</strong><small>static NodeList · a snapshot</small></div>
<div class="stat live"><code>getElementsByClassName('item')</code><strong id="live-count">2</strong><small>live HTMLCollection · always current</small></div>
</div>
<div class="row">
<button id="add">+ Add item</button>
<button id="remove" class="ghost">Remove first</button>
</div>
</main>
CSS
body { margin: 0; background: #f5f7f8; color: #1d2939; }
.panel { padding: 16px; display: grid; gap: 12px; }
header { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
h1 { font-size: 18px; margin: 0; }
.muted { font-size: 13px; color: #667085; }
.cart { list-style: none; margin: 0; padding: 8px; background: #fff; border: 1px solid #e4e7ec; border-radius: 12px;
display: flex; flex-wrap: wrap; gap: 8px; min-height: 36px; }
.item { font-size: 13px; padding: 6px 10px; border-radius: 8px; background: #f0fdfa; color: #115e59; border: 1px solid #99f6e4; }
.stats { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.stat { display: grid; gap: 4px; background: #fff; border: 1px solid #e4e7ec; border-radius: 12px; padding: 12px; }
.stat code { font-size: 12px; color: #475467; }
.stat strong { font-size: 32px; line-height: 1.1; color: #98a2b3; }
.stat.live strong { color: #0f766e; }
.stat small { color: #667085; }
.row { display: flex; flex-wrap: wrap; gap: 8px; }
button { font: inherit; font-size: 14px; font-weight: 600; padding: 8px 14px; border-radius: 10px; cursor: pointer;
border: 1px solid #0f766e; background: #0f766e; color: #fff; }
button:hover { background: #115e59; }
button.ghost { background: #fff; color: #344054; border-color: #d0d5dd; }
button:focus-visible { outline: 3px solid #99f6e4; outline-offset: 2px; }
JavaScript
const cart = document.querySelector('#cart');
const snapshot = cart.querySelectorAll('.item'); // taken once, never updates
const live = cart.getElementsByClassName('item'); // re-checked on every read
const extras = ['Sticky notes', 'Stapler', 'Highlighter', 'Ruler', 'Eraser'];
function show() {
document.querySelector('#static-count').textContent = snapshot.length;
document.querySelector('#live-count').textContent = live.length;
console.log('snapshot:', snapshot.length, '| live:', live.length);
}
document.querySelector('#add').addEventListener('click', () => {
const li = document.createElement('li');
li.className = 'item';
li.textContent = extras[live.length % extras.length];
cart.append(li);
show();
});
document.querySelector('#remove').addEventListener('click', () => {
cart.firstElementChild?.remove();
show();
});
document.querySelector('#add').click();
document.querySelector('#add').click();
Add and remove items. The snapshot from querySelectorAll never changes; the live collection always matches the page.
Live collections sound convenient but have a sharp edge: if you loop over getElementsByClassName('item') and remove the class inside the loop, the collection shrinks under you and you skip every second item. The fix is to loop over a static copy: [...collection] or querySelectorAll.
Walking the tree: closest, matches, parents and siblings
Often you already have one element (usually the one the user clicked) and need a relative. closest(selector) starts with the element itself and walks up through its ancestors until one matches, returning null if none do. matches(selector) just answers “does this element match?”.
// The click landed on the badge, the td, or the button...
const row = event.target.closest('tr'); // ...we always get the row
const orderId = row.dataset.id; // 'RZ-1043'
if (event.target.matches('.link')) {
openOrder(orderId);
}
const table = row.parentElement.parentElement; // tbody, then table
const nextRow = row.nextElementSibling; // null on the last row
const cells = row.children; // live HTMLCollection
Click around the orders table and watch the inspector:
Try it: Traversal explorer: closest, parent and siblings
HTML
<main class="orders">
<header><h1>Orders</h1><span class="muted">Click anything inside a row</span></header>
<div class="table-wrap">
<table>
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Total</th><th></th></tr></thead>
<tbody id="rows">
<tr data-id="RZ-1042"><td>#RZ-1042</td><td>Anjali M.</td><td><span class="badge paid">Paid</span></td><td>₹2,340</td><td><button class="link">View</button></td></tr>
<tr data-id="RZ-1043"><td>#RZ-1043</td><td>Rahul K.</td><td><span class="badge pending">Pending</span></td><td>₹890</td><td><button class="link">View</button></td></tr>
<tr data-id="RZ-1044"><td>#RZ-1044</td><td>Fatima S.</td><td><span class="badge paid">Paid</span></td><td>₹5,120</td><td><button class="link">View</button></td></tr>
<tr data-id="RZ-1045"><td>#RZ-1045</td><td>Joel T.</td><td><span class="badge refund">Refunded</span></td><td>₹1,460</td><td><button class="link">View</button></td></tr>
</tbody>
</table>
</div>
<aside class="inspector" id="inspector"><p class="muted">Nothing selected yet.</p></aside>
</main>
CSS
body { margin: 0; background: #f5f7f8; color: #1d2939; }
.orders { padding: 16px; display: grid; gap: 12px; }
header { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
h1 { font-size: 18px; margin: 0; }
.muted { font-size: 13px; color: #667085; margin: 0; }
.table-wrap { overflow-x: auto; background: #fff; border: 1px solid #e4e7ec; border-radius: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { text-align: left; font-size: 12px; font-weight: 600; color: #667085; background: #f9fafb; padding: 10px 12px; }
td { padding: 10px 12px; border-top: 1px solid #f2f4f7; white-space: nowrap; }
tbody tr { cursor: pointer; transition: background .15s; }
tbody tr:hover { background: #f9fafb; }
tbody tr.selected { background: #f0fdfa; box-shadow: inset 3px 0 0 #0f766e; }
.badge { font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 999px; }
.paid { background: #dcfce7; color: #166534; }
.pending { background: #fef3c7; color: #92400e; }
.refund { background: #f2f4f7; color: #475467; }
.link { font: inherit; font-weight: 600; color: #0f766e; background: none; border: 0; cursor: pointer; padding: 4px 6px; border-radius: 6px; }
.link:hover { background: #ccfbf1; }
.inspector { background: #0f172a; color: #e2e8f0; border-radius: 12px; padding: 12px 16px; font-size: 13px; }
.inspector dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; }
.inspector dt { color: #94a3b8; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.inspector dd { margin: 0; color: #5eead4; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
@media (max-width: 480px) { .inspector dl { grid-template-columns: 1fr; gap: 2px; } .inspector dd { margin-bottom: 6px; } }
JavaScript
const rows = document.querySelector('#rows');
const inspector = document.querySelector('#inspector');
// A short label like 'span.badge.paid' or 'td'.
function describe(el) {
if (!el) return 'null';
const classes = [...el.classList].map((c) => '.' + c).join('');
return el.tagName.toLowerCase() + classes;
}
rows.addEventListener('click', (event) => {
const target = event.target;
const row = target.closest('tr');
if (!row) return;
rows.querySelector('.selected')?.classList.remove('selected');
row.classList.add('selected');
const cell = target.closest('td');
const facts = [
['event.target', describe(target)],
["closest('tr').dataset.id", row.dataset.id],
['target.parentElement', describe(target.parentElement)],
['cell.previousElementSibling', cell.previousElementSibling?.textContent ?? 'null'],
['cell.nextElementSibling', describe(cell.nextElementSibling)],
['row.children.length', String(row.children.length)],
["target.matches('.link')", String(target.matches('.link'))],
];
const dl = document.createElement('dl');
for (const [label, value] of facts) {
const dt = document.createElement('dt');
const dd = document.createElement('dd');
dt.textContent = label;
dd.textContent = value;
dl.append(dt, dd);
}
inspector.replaceChildren(dl);
console.log('Clicked', describe(target), 'in order', row.dataset.id);
});
rows.querySelector('tr:nth-child(2) .link').click();
Click any cell, badge or View button. The panel shows what event.target is and where closest(), parentElement and the sibling properties lead.
closest is the most useful traversal method you will learn. It makes your code independent of the exact nesting: if a designer wraps the button in a <span>, closest('tr') still finds the row, while target.parentElement.parentElement breaks.
Keeping references fresh
A variable that holds an element is a reference to one object in the tree, not a saved search. That has two consequences worth understanding before you build bigger pages.
First, select once and reuse. Put your querySelector calls at the top of the script or component, store the results in const variables with clear names (cartBadge, searchInput), and use those everywhere. The code is faster, and when the markup changes you fix the selector in one place.
Second, references can go stale. If some code rebuilds a section with innerHTML or replaceChildren, the old elements are thrown away and new ones are created. Your variable still points at the old object, which is no longer on the page: changing it does nothing visible, and no error tells you why. You can detect this with el.isConnected, which is false for a detached element. The robust fixes are to select containers that never get replaced (the list, not its items) and to find items at the moment you need them, usually with closest() from the element the user interacted with.
const list = document.getElementById('list'); // never replaced
let firstItem = list.firstElementChild; // may go stale
list.replaceChildren(...newItems);
console.log(firstItem.isConnected); // false: detached
firstItem = list.firstElementChild; // select again
This is also why lesson 2.2 teaches event delegation: one listener on a stable container keeps working however often the items inside it are rebuilt.
Example: Ralfiz Store inventory inspector
The lab builds a small admin tool. The HTML already contains eight products with data-stock, data-price and data-category. The script:
- Selects the list once with
getElementByIdand the products withquerySelectorAll. - Counts products and out-of-stock items with an attribute selector, and marks the latter with a class.
- Finds the cheapest product by looping over the NodeList.
- Uses one click listener on the list and
closest('.product')to show details of whichever card was clicked. - Uses
matches()to dim products that do not fit the chosen category filter.
const products = list.querySelectorAll('.product');
let cheapest = products[0];
for (const item of products) {
if (Number(item.dataset.price) < Number(cheapest.dataset.price)) {
cheapest = item;
}
}
cheapestEl.textContent = cheapest.querySelector('h2').textContent;
filterBar.addEventListener('click', (event) => {
const selector = event.target.closest('button')?.dataset.filter;
if (!selector) return;
products.forEach((p) => p.classList.toggle('dim', !p.matches(selector)));
});
Common mistakes
1. Forgetting that querySelector can return null.
// Wrong: throws if #promo is not on this page
document.querySelector('#promo').hidden = false;
// Fix: check, or use optional chaining when absence is fine
const promo = document.querySelector('#promo');
if (promo) promo.hidden = false;
2. Calling array methods on a NodeList or HTMLCollection.
// Wrong: NodeList has no map, HTMLCollection has no forEach
const titles = document.querySelectorAll('h2').map((h) => h.textContent);
// Fix: convert first
const titles = [...document.querySelectorAll('h2')].map((h) => h.textContent);
3. Adding a # to getElementById, or leaving it off in querySelector. getElementById('#cart') looks for an id that literally starts with #. Write getElementById('cart') or querySelector('#cart'). For ids from data that may contain special characters, use querySelector('#' + CSS.escape(id)).
4. Walking with firstChild and nextSibling. They land on whitespace text nodes. Use firstElementChild and nextElementSibling.
5. Querying inside a loop. document.querySelector('#total') inside a 500-item loop does the same work 500 times. Select once, store it in a const, reuse it.
Interview and real-world notes
- “NodeList vs HTMLCollection?” HTMLCollection holds only elements and is always live; NodeList can hold any node, is static from
querySelectorAlland live fromchildNodes, and hasforEach. - “How would you find the row of a clicked button?”
event.target.closest('tr'), and mention that it includes the element itself. This is the heart of event delegation (lesson 2.2). - Prefer stable hooks for JavaScript: ids for unique elements and
data-*attributes (likedata-action="delete") rather than styling classes, so a CSS refactor does not break your script. Testing tools such as Playwright and Testing Library encourage the same habit. getElementByIdis marginally faster thanquerySelector, but at this scale readability wins; do not micro-optimise selectors.
Predict the output
Read each snippet and decide what it prints before you run it. The answers are at the end of this section.
Snippet 1. How many child nodes and child elements does the list have?
const ul = document.createElement('ul');
ul.innerHTML = '<li>Pen</li> <li>Book</li>';
console.log(ul.childNodes.length, ul.children.length);
- A
2 2 - B
3 3 - C
3 2 - D
2 3
Snippet 2. Both lists are taken before the new li is appended. What is logged?
const ul = document.createElement('ul');
ul.append(document.createElement('li'), document.createElement('li'));
const live = ul.getElementsByTagName('li');
const snap = ul.querySelectorAll('li');
ul.append(document.createElement('li'));
console.log(live.length, snap.length);
- A
3 2 - B
3 3 - C
2 2 - D
2 3
Snippet 3. What does closest() return in each case?
const card = document.createElement('div');
card.className = 'card';
const btn = document.createElement('button');
card.append(btn);
console.log(btn.closest('.card') === card, btn.closest('button') === btn, btn.closest('.missing'));
- A
true false null - B
true true null - C
false true undefined - D
true true undefined
Answers
- C. The single space between the two li tags becomes a text node. childNodes counts it (3 nodes); children only counts elements (2).
- A. getElementsByTagName returns a live HTMLCollection, so it sees the third li. querySelectorAll returned a static NodeList when it ran, so it still has 2.
- B. closest() checks the element itself first, so btn.closest('button') is btn. When nothing matches all the way up, it returns null, not undefined.