What the DOM Actually Is
When a browser loads a page it does not keep your HTML as text. It parses the markup into a tree of objects — one for each element, attribute and piece of text — and that tree is the Document Object Model. Your JavaScript never touches the original HTML file at all. It reads and edits this in-memory tree, and the browser repaints the screen to match whatever the tree now says.
Two things follow from that, and both matter. First, changes made by JavaScript are not saved anywhere: refresh the page and the tree is rebuilt from the original HTML, and your edits are gone. Storing anything that must survive a refresh is Lesson 25's job. Second, the tree really is a tree, with parents, children and siblings — which is what makes it possible to express ideas like "the table row containing this button" or "every link inside this navigation bar".
document is your handle on that tree, and everything in this lesson starts from it. It exists only inside a browser. Node.js has no page, which is why Node code that copies a DOM example fails with ReferenceError: document is not defined — the JavaScript is fine, the toolbox is different.
Selection is always the first half of any DOM task: find an element, then do something to it. This lesson is entirely about the finding, and Lesson 16 is about the doing. It is worth taking slowly, because a surprising proportion of the DOM bugs beginners hit are selection problems wearing a disguise.
// The tree, from the top
console.log(document.title);
console.log(document.body);
console.log(document.documentElement.lang); // the <html> element
// Find, then act - the shape of every DOM task
const heading = document.querySelector('h1');
heading.textContent = 'Changed by JavaScript';
// The change lives in memory only; a refresh rebuilds the tree from the HTML - Open any page, press F12 and type
document.bodyin the Console. What comes back is the live object, and expanding it shows the same structure you see in the Elements tab. The Elements tab is a view of the DOM, not of your source file.
querySelector and querySelectorAll
These two are the ones to learn first, because they take CSS selectors — exactly the same syntax you already use for styling. If you can write a CSS rule that targets something, you can select it in JavaScript with the identical string. That is one syntax to learn instead of two.
document.querySelector(sel) returns the first match, or null when nothing matches. document.querySelectorAll(sel) returns every match as a NodeList, which is empty rather than null when nothing matches. Both search the whole document, top to bottom, in the order the elements appear.
The difference in what they return when nothing is found matters enormously. querySelector giving back null is the direct cause of TypeError: Cannot read properties of null, the most common error in all of DOM programming — you selected something that is not there and then reached for a property on it. querySelectorAll is safer by default: looping over an empty list simply does nothing at all.
A NodeList is array-like but is not an array. It has length and it has forEach, and for...of works on it — but map, filter and reduce are not there, and calling one gives is not a function. Convert with Array.from(list) or the shorter [...list] whenever you want the full set of array methods from Lesson 10.
// The first match, or null
const firstCard = document.querySelector('.card');
const submitBtn = document.querySelector('#submit');
const emailBox = document.querySelector('form input[type="email"]');
// Every match, as a NodeList
const cards = document.querySelectorAll('.card');
console.log(cards.length);
// null versus empty - the important difference
console.log(document.querySelector('.does-not-exist')); // null
console.log(document.querySelectorAll('.does-not-exist').length); // 0
// forEach and for...of work on a NodeList
cards.forEach(card => console.log(card.textContent.trim()));
for (const card of cards) {
card.classList.add('seen');
}
// Convert when you want map, filter or reduce
const titles = [...cards].map(c => c.textContent.trim());
console.log(titles); - Any CSS selector works, including the complicated ones:
'ul.menu > li:first-child','input[required]','.card:not(.hidden)'. Test the selector in the console first and you will know instantly whether the problem is the selector or the code around it.
getElementById and the Older Family
Before querySelector existed there were several separate methods, and you will still meet them constantly in tutorials, in older codebases and in exam papers, so they are worth being able to read even if you write mostly modern code.
getElementById('output') takes a bare id with no # in front, returns one element or null, and is the fastest selector available because browsers keep an internal index of ids. For a single, known element it remains a perfectly good choice, and the name makes the intent unmistakable at the call site.
getElementsByClassName, getElementsByTagName and getElementsByName return collections. Notice the plural in every one of those names. Forgetting it and treating the result as a single element is a classic beginner error, and a quiet one: a collection has no textContent, so assigning to it does nothing and reports nothing.
The practical advice is to use querySelector and querySelectorAll for almost everything, keeping getElementById as the one exception when you have an id and want the intent to be obvious. Mixing the entire family across one file gives every future reader more to hold in their head for no benefit.
// No # in front for getElementById
const output = document.getElementById('output');
const same = document.querySelector('#output'); // the same element
// The plural methods return collections, not elements
const items = document.getElementsByClassName('item');
console.log(items.length);
// items.textContent = 'x'; // does nothing at all - it is a collection
console.log(items[0]); // THIS is an element
// Missing id: null, not an error
console.log(document.getElementById('missing')); // null
// Ids are supposed to be unique. If two elements share one,
// getElementById returns only the first, and nothing warns you. - An element with an id also becomes a global variable with that name in browsers. It works, and you should never rely on it — the moment somebody renames the id, the code breaks with a
ReferenceErrorin a place that has nothing to do with the HTML.
Static NodeList versus Live HTMLCollection
This is the distinction that catches people out, and it explains a category of bug that otherwise looks like the browser misbehaving.
querySelectorAll returns a static NodeList — a snapshot taken at the instant you called it. Add a matching element to the page a moment later and your list does not grow. Remove one and your list still contains it. What you hold is a fixed set of references, which is almost always what you want.
getElementsByClassName and getElementsByTagName return a live HTMLCollection that keeps updating itself as the page changes. That sounds helpful and produces one spectacular bug. A loop that removes a class while iterating over a live collection of elements carrying that class skips every second element, because the collection shrinks underneath the loop while the counter keeps rising. Worse, a while loop over a live collection whose body adds a matching element never terminates.
The reliable habit is short: use querySelectorAll. If you have a live collection for any reason, convert it to a plain array with [...collection] before you modify anything. The copy is a snapshot and cannot shift under you.
const staticList = document.querySelectorAll('.item'); // a snapshot
const liveList = document.getElementsByClassName('item'); // keeps updating
console.log(staticList.length, liveList.length); // e.g. 3 3
// Add one more matching element to the page
const extra = document.createElement('div');
extra.className = 'item';
document.body.appendChild(extra);
console.log(staticList.length); // still 3 - it was a snapshot
console.log(liveList.length); // now 4 - it updated itself
// The classic live-collection bug
const live = document.getElementsByClassName('active');
for (let i = 0; i < live.length; i++) {
live[i].classList.remove('active'); // the collection shrinks as you go
}
// Roughly half of them survive.
// Take a copy first and the problem disappears
[...document.getElementsByClassName('active')]
.forEach(el => el.classList.remove('active')); - A NodeList from
querySelectorAllholds references to real elements, so it is a snapshot of which elements matched, not of what they contained. If you change an element's text afterwards, the element in your list shows the new text — it is the same object.
Timing: Why Your Element Is null
If querySelector returns null for an element you can plainly see on the page, there are only three possible causes. Checking them in order takes about a minute and will save you a great deal of guessing.
First, the selector is wrong. A missing dot, a capital letter in the wrong place, an id spelled submitBtn in the HTML and submitbtn in the JavaScript. The console settles this in seconds: paste the exact selector string into document.querySelector('...') there and see what comes back.
Second, and far more common, the script ran before the element existed. A plain script tag inside <head> runs while the browser is still building the page, so anything written below it in the HTML has not been created yet — your selector is correct and there is genuinely nothing there to find. The fix is the defer attribute from Lesson 1: the browser downloads the file straight away and runs it only once the page is complete. Placing the script tag at the very end of <body> achieves the same thing and is the older approach.
Third, the element genuinely does not exist yet, because your own code will create it later or because it belongs to content fetched from a server after the page rendered. Attaching a listener to something that does not exist cannot work, however carefully you spell the selector. The answer there is to attach the listener to a parent that does exist and let events travel up to it — event delegation, which Lesson 18 covers in full.
// In HTML: a plain script in <head> runs too early
// <script src="app.js"></script>
// With defer, it runs after the page is built
// <script src="app.js" defer></script>
// DOMContentLoaded is the manual version of the same idea
document.addEventListener('DOMContentLoaded', () => {
const heading = document.querySelector('h1');
console.log(heading); // the element, not null
});
// Check before using, whenever the element may legitimately be absent
const banner = document.querySelector('.promo-banner');
if (banner) {
banner.textContent = 'Sale ends today';
}
// Or optional chaining, when doing nothing is an acceptable outcome
document.querySelector('.promo-banner')?.remove(); deferonly works on external scripts — ones with asrc. On an inline block it does nothing at all, which is why an inline script in the head still runs too early no matter what you add to the tag.
Narrowing the Search and Moving Around the Tree
querySelector is not only available on document — every element has it. Calling it on an element searches inside that element only, which is how you express "the price inside this card" rather than "the first price anywhere on the page". In a list of repeated components this is the difference between working code and code that stubbornly updates the first row every time.
closest(selector) searches in the opposite direction. Starting at the element you call it on, it walks up through the ancestors and returns the first one that matches, or null if it reaches the top without finding one. It is the standard way to get from a clicked delete button to the table row or card that contains it, and it is the natural partner to event delegation.
For structural moves there are parentElement, children, firstElementChild, lastElementChild and nextElementSibling. Prefer these Element versions over the older parentNode, childNodes and firstChild, because the older names count text nodes too — and the whitespace and line breaks between your tags are text nodes. That is why firstChild so often turns out to be a line break rather than the element you were aiming at.
As a rule, prefer closest and a scoped querySelector over a chain of parentElement.parentElement. A chain like that encodes the exact shape of your markup into your JavaScript, so it breaks the moment somebody wraps the block in one more container — and nothing tells you, because parentElement of the wrong element is still a perfectly valid element.
// Scoped search: only inside this card
const card = document.querySelector('.card');
const price = card.querySelector('.price'); // not the page's first .price
// Upwards: from a clicked button to the row that holds it
function onDeleteClick(event) {
const row = event.target.closest('tr');
if (row) row.remove();
}
// Structural navigation - use the Element versions
const list = document.querySelector('ul');
console.log(list.children.length); // elements only
console.log(list.childNodes.length); // includes whitespace text nodes
console.log(list.firstElementChild); // the first list item
console.log(list.firstChild); // very often a text node
// Fragile - one extra wrapper in the markup breaks this
// const row = btn.parentElement.parentElement.parentElement;
// Robust - says what it means and survives markup changes
// const row = btn.closest('tr'); querySelector(sel)— the first match, ornullquerySelectorAll(sel)— every match as a static NodeList; empty when nonegetElementById(id)— no#, fastest, one element ornullgetElementsByClassName— a live collection; copy it before modifying anythingel.querySelector(sel)— searches insideelonlyel.closest(sel)— searches upwards through the ancestors[...nodeList]— convert when you wantmaporfilter- Always handle
null: check withif, or use?.
- Test every selector in the browser console before committing it to a file. Typing
document.querySelectorAll('.card .price').lengthand seeing the count come back settles in two seconds what can otherwise cost twenty minutes of adding console logs.
