An introduction to the DOM, the browser's live in-memory tree of typed nodes that sits beneath every page and underpins the React you write later.
You sign in to an app. The email field autofills with ada@example.com. Curious, you open DevTools, find the <input> in the Elements panel, and it reads value="ada@example.com", exactly what you see on screen. Then you hit “View Source” on the same page, scroll to the same input, and the markup says <input value="">. Empty. Two views of one element, two different answers. Which one is lying?
Neither. They are showing you two different things, and the gap between them is what this chapter is built around. “View Source” fetches the original bytes the server sent and shows you the HTML string the parser read, a photograph taken the moment the page loaded. The Elements panel shows you the live tree the parser built from those bytes, and the browser’s autofill, your keystrokes, and every script on the page have been mutating that tree ever since. The source is frozen, while the tree is alive.
Back in the chapter “How a request becomes a page”, you drew the parser as one box in the render pipeline: HTML bytes go in, the DOM comes out. This lesson opens that box. You will stop treating the DOM as a pipeline stage and start treating it as what it actually is: a live data structure made of typed objects, sitting in memory, that JavaScript reads and rewrites. By the end you will read the Elements panel as a tree of typed objects, know which of four lookup methods to reach for, and predict a classic bug that catches anyone who loops over the tree while changing it. The reason this matters comes later, in React. There you almost never touch these APIs directly, because JSX builds the tree and React owns it. But the substrate still leaks through, in refs, portals, hydration errors, and DevTools, and when it does, an experienced engineer reads the leak in DOM terms. This lesson is where you learn to read it.
The DOM is live program state, not the HTML you sent
One distinction sits underneath everything in this chapter:
That distinction sounds abstract until you watch what touches the tree. The parser builds it once, at load, from the byte stream. After that, the same tree gets written to from several directions:
Browser features. Autofill drops your email into the input. Toggling a <details> element flips its open state. The browser is mutating the tree on your behalf.
Your own JavaScript. Set element.textContent = 'Saved' and that node’s text changes in the tree, immediately.
Every React render. When you get to React, each render reconciles against this same tree and writes the differences into it. (You will see exactly how when the course reaches React; for now just know it writes here too.)
All of those write to one tree, the live one. None of them touch the original HTML bytes, because those bytes are gone: the server sent them, the parser consumed them, and they were never kept around as something the page can edit.
So when “View Source” and the Elements panel disagree, that is not a bug in your code or in the browser. “View Source” re-fetches the original document from the server, a fresh photograph of what was sent. The Elements panel serializes the current live tree back into HTML-looking text so you can read it. They differ because one is the snapshot and the other is the live state, and time has passed between them. The diagram below puts the two side by side.
HTML bytes what the server sent / View-Source
<form>
<input value="">
</form>
a byte string, captured once
parser builds once
JS + browser mutate
Live DOM tree what DevTools shows
form
└─ input.value = "ada@example.com"
a property, rewritten by autofill
The parser builds the tree once from the bytes; the browser and JavaScript mutate the tree forever after.
Calling the DOM a “tree of objects” raises an obvious question: objects of what? Every member of the tree is an instance of a class, and those classes form an inheritance chain. Knowing the chain lets you look at a type in your editor and tell at once what you can and can’t do with the node it points at. We’ll build the chain from the bottom up, since each level adds a little to the one below it.
At the base sits Node, the abstract base class that every tree member inherits from. Elements, text, comments, even the document itself: all of them are Nodes. Node carries the members that are about being in a tree: parentNode, childNodes, nodeType. Anything that lives in the tree has these, because everything in the tree is a node.
One step up is Element, a Node that has a tag and attributes. This is where getAttribute, classList, and querySelector live. Element covers HTML elements and SVG elements, so it is deliberately generic: it knows you have a tag, but not which one.
Up again is HTMLElement, the HTML-flavored element specifically. It adds the members every HTML element shares: style, dataset, hidden, tabIndex. When DOM code wants to be generic but still HTML-aware, this is the level it types against.
At the top sit the tag-specific subclasses, one per meaningful tag, each adding the members only that tag has:
HTMLInputElement adds .value and .checked
HTMLAnchorElement adds .href
HTMLImageElement adds .src and .naturalWidth
HTMLButtonElement, HTMLFormElement, and so on
The diagram below lays the chain out so you can see what each level adds on top of the one below.
Node
adds
parentNode
childNodes
nodeType
Element
+ adds
getAttribute()
classList
querySelector()
HTMLElement
+ adds
style
dataset
tabIndex
HTMLInputElement
+ adds
.value
.checked
also Nodes — recognize, don’t memorize
Text·Comment·Document·DocumentFragment
Each level inherits everything to its left and adds a few members of its own. Type against the level that introduces the member you need.
The chain earns its keep the moment you read a type in a function signature. Suppose you come across this:
const focusEmail = (field:HTMLInputElement) => {
field.focus();
field.select();
};
const readValue = (node:Element) => {
return node.value;
};
The second function does not type-check. Element is too generic: it knows the node has a tag, but .value belongs to HTMLInputElement, further down the chain. The fix is to type the parameter as HTMLInputElement, the level where .value is introduced. That is the whole skill here: read the type, place it on the ladder, and you can see straight away which members are in reach.
This is also where the chain pays off in React. When you reach refs later in the React material, you will write useRef<HTMLInputElement>(null), and now you know that generic isn’t React ceremony. You are naming the exact level of this hierarchy you need, so the node the ref hands you has .value on it. React doesn’t invent the type; it borrows the one the platform already defined.
Not every node is an element, though. A handful of other node types round out the tree. You need to recognize these far more than you need to use them, because they tend to show up in a debugger trace or surprise you while you walk the tree:
Text: the run of characters between tags. Whitespace counts here: the newline and indentation between two tags is itself a Text node.
Comment: an HTML comment, <!-- … -->, parked in the tree.
Document: the root of the whole tree. The global document is this node.
DocumentFragment: a lightweight, off-tree container you can build nodes inside before attaching them. Tuck this name away, because it is the substrate behind React Portals, which you will meet later in the React material.
DocumentType: the <!DOCTYPE html> declaration.
In practice, the most you’ll do with these is read something like node.nodeType === Node.TEXT_NODE in a stack trace and know what it means: this node is a Text node, not an element. Aim to recognize them rather than memorize their APIs.
The exercise below drills the one thing that actually sticks: which level introduces which member. Sort each item into the class that first adds it.
Drop each member onto the level of the hierarchy that *introduces* it — the class where it first appears, not just one that inherits it.
Drag each item into the bucket it belongs to, then press Check.
Once you accept the DOM is a tree of objects, the next question is how you get a reference to a specific one. There are four methods worth knowing, and each answers a different question. Treat them as a small toolkit where the question you are asking picks the tool.
document.getElementById(id) is the narrowest and fastest. It matches one element by its id attribute, as an exact, case-sensitive match, and returns that element or null. Reach for it when you control the id and want exactly that node.
element.querySelector(selector) and element.querySelectorAll(selector) take any CSS selector and search the entire descendant subtree of the element you call them on, or of document if you call them on document. querySelector returns the first match or null; querySelectorAll returns a NodeList of every match. This is the universal reach: when an id isn’t enough, a selector almost always is.
element.closest(selector) walks the other direction, up the ancestor chain. It starts from the element itself and returns the nearest ancestor that matches the selector, or null. Think of it as “find the meaningful container I’m inside.” When you have a clicked element and need the row or form it belongs to, closest finds it. It does real work later in this chapter, when you reach event delegation.
element.matches(selector) is the simplest: it returns a boolean answering whether this element matches the selector. It shows up in event delegation too, where you use it to ask “is the thing that got clicked the kind of thing I care about?”
The walkthrough below uses all four against one small piece of markup, so you can see how the question shifts from method to method.
// <nav id="main-nav">
// <ul>
// <li><a class="active" href="/">Home</a></li>
// <li><a href="/pricing">Pricing</a></li>
// </ul>
// </nav>
const nav = document.getElementById('main-nav');
const links = nav.querySelectorAll('a');
const firstLink = links[0];
const listItem = firstLink.closest('li');
const isActive = firstLink.matches('.active');
You control the id and want exactly one node, so getElementById is the sharpest tool: an exact, case-sensitive match on the id attribute. It returns the element or null.
// <nav id="main-nav">
// <ul>
// <li><a class="active" href="/">Home</a></li>
// <li><a href="/pricing">Pricing</a></li>
// </ul>
// </nav>
const nav = document.getElementById('main-nav');
const links = nav.querySelectorAll('a');
const firstLink = links[0];
const listItem = firstLink.closest('li');
const isActive = firstLink.matches('.active');
An id can’t express “every link inside the nav”, but a CSS selector can. querySelectorAll searches the whole subtree under nav and returns every <a> it finds.
// <nav id="main-nav">
// <ul>
// <li><a class="active" href="/">Home</a></li>
// <li><a href="/pricing">Pricing</a></li>
// </ul>
// </nav>
const nav = document.getElementById('main-nav');
const links = nav.querySelectorAll('a');
const firstLink = links[0];
const listItem = firstLink.closest('li');
const isActive = firstLink.matches('.active');
Now you have a link and need the <li> wrapping it. closest walks up from the link through its ancestors and stops at the first <li>. This is the move you reach for from inside a click handler.
// <nav id="main-nav">
// <ul>
// <li><a class="active" href="/">Home</a></li>
// <li><a href="/pricing">Pricing</a></li>
// </ul>
// </nav>
const nav = document.getElementById('main-nav');
const links = nav.querySelectorAll('a');
const firstLink = links[0];
const listItem = firstLink.closest('li');
const isActive = firstLink.matches('.active');
Here you don’t need to find a node; you need a yes/no about one you already have. matches answers “does this element match .active?” with a boolean.
1 / 1
A compact way to remember which to reach for:
| You want… | Method |
| --- | --- |
| an exact element by id | getElementById |
| a CSS match somewhere in the subtree | querySelector / querySelectorAll |
| the nearest matching ancestor | closest |
| a yes/no on one element | matches |
One piece of framing tells you where all four of these belong. In a React codebase you do not pepper your components with querySelector calls. You declare what the UI is in JSX and let React build and own the tree. These four methods show up only when you deliberately step outside React to the raw platform: inside an effect, inside a ref callback, or inside glue code wiring up a third-party library. They are an escape hatch, not a daily tool. Learn them so you recognize them when the hatch opens, and don’t go looking for excuses to open it.
Tree-walking: element-flavored vs node-flavored navigation
Sometimes you have one node and want a neighbor: its parent, its first child, the next sibling. The DOM gives you two parallel families of navigation properties, and the difference between them is the part of this section most likely to trip you up.
The family to reach for by default is the element-flavored one. It walks element to element, skipping Text and Comment nodes entirely:
parentElement: the parent, if it’s an element
children: the element children
firstElementChild / lastElementChild
nextElementSibling / previousElementSibling
Because these skip text and comments, they behave the way you’d intuitively expect: “the first child” is the first element, not whatever whitespace happens to sit there.
The other family is node-flavored. It sees everything in the tree, text nodes included:
parentNode
childNodes
firstChild / lastChild
nextSibling / previousSibling
This is where people get caught. Remember that the whitespace between two tags is a Text node. So in almost any real, indented markup, firstChild is not the element you wrote first; it’s the newline and indentation before it. Predict what this prints:
Given this markup, what do the two logs print, one per line?
Predict what this program prints, then press Check.
// <ul id="menu">
// <li>Home</li>
// <li>Pricing</li>
// </ul>
const menu = document.getElementById('menu');
console.log(menu.firstChild.nodeName);
console.log(menu.firstElementChild.nodeName);
The markup is indented, so the first thing inside <ul> is the newline-plus-spaces before the first <li> — that’s a Text node, and its nodeName is #text. firstChild sees it; firstElementChild skips straight past it to the first element, the <li> (whose nodeName is the uppercase tag name LI). This is exactly why you default to the element-flavored properties: they don’t get fooled by whitespace.
Expected output
So the rule has a default and an exception. Default to the *Element* members, which give you the predictable element-to-element walk you almost always want, and reach for the node-flavored family only when you genuinely need to touch text or comment nodes. This element-flavored surface is also exactly what you climb when this chapter gets to event delegation: a click lands on some deep element, and you walk up toward a meaningful ancestor.
Live vs. static collections: the iterate-while-mutating trap
The second idea worth slowing down for is this one, because it produces a silent bug that has cost people hours. Some of the methods you just learned hand back collections of nodes, and not all collections behave the same way over time.
element.children returns an HTMLCollection, and it is live. “Live” means it is a window onto the tree as it is right now, not a copy. Add or remove a child and the collection’s contents, along with its .length, change underneath you in the same instant.
element.childNodes returns a NodeList, and it is also live. Being node-flavored, it includes text nodes.
element.querySelectorAll(...) also returns a NodeList, but this one is static. It is a snapshot frozen at the moment you called it. Change the tree afterward and the snapshot doesn’t notice; it still holds the nodes that matched back then.
That live-versus-static split is harmless until you iterate a live collection while changing it, where it goes wrong like this:
if (list.children[i].classList.contains('done')) {
list.children[i].remove();
}
}
Skips elements. Removing a child shrinks list.childrenimmediately, because it is live. When you remove the element at index i, every later element shifts down by one: the next item now sits at index i, but the loop has already moved on to i + 1, so it steps right over it. Remove two adjacent .done items and the second one survives.
const list = document.getElementById('list');
for (const itemof[...list.children]) {
if (item.classList.contains('done')) {
item.remove();
}
}
Visits every match. Spreading the live collection into a real array takes a snapshot: a fixed list of the elements as they were before you touched anything. Removals from the tree no longer shift the thing you’re iterating, because you’re iterating the array, not the live collection. The whole fix is one pair of brackets.
The mechanism is index drift, and it’s easier to see than to read about. Scrub through the buggy loop one tick at a time below.
i= 0list.children.length= 3A is .done → remove it
[0]
A.done
i = 0
[1]
B.done
[2]
C
Start, i = 0. The collection is live: [A, B, C]. Index 0 points at A, which is .done, so the loop calls A.remove().
i= 1list.children.length= 2i jumped past B
Aremoved
shifted ↓ the rest
[0]
B.done
[1]
C
i = 1
After removing A, i = 1. Removing A shifted everyone down: B now sits at index 0, C at index 1. But i already advanced to 1, so the loop reads C and steps right over B. B is never visited.
i= 2list.children.length= 2i ≥ length → stop
Aremoved
shifted ↓ the rest
[0]
B.done
[1]
C
After checking C, i = 2.i is now past the shrunken length of 2, so the loop stops. C wasn’t .done, so nothing more was removed. Final result: [B, C]. B should have been removed, but the index drift skipped it.
So the habit is simple: snapshot before you mutate. Spread the collection into an array with [...element.children], or use Array.from(element.children) for the same effect. The snapshot is immune to tree changes, and as a bonus you now hold a real array, which unlocks map, filter, and forEach. A querySelectorAll result is already static, so it won’t drift on you, but spreading it into an array is still worth doing the moment you want those array methods, since a NodeList only gives you forEach, not map or filter.
You have now met the whole substrate: a live tree of typed nodes, a way to reach into it, a way to walk it, and the collection gotcha. That leaves the question hanging over the whole lesson. If React builds and owns the tree, when do you ever touch any of this?
There are five places. None of them is a daily occurrence, but all are worth recognizing, because each is the React abstraction leaking and showing you the platform underneath. The grid below is your map.
Refs to imperative DOM
Focus an input, scroll an element into view, measure its size, or hand it to a third-party library: things JSX can’t express. A ref gives you the actual node, and that node is one of the typed objects from this lesson. The React material owns useRef; this lesson named the node it points at.
Portals
React can render a component’s output into a different part of the tree, such as a modal escaping its parent’s overflow. The substrate is the off-tree DocumentFragment container you met earlier. Covered when you reach portals later in the React material.
Hydration
When HTML is rendered on the server and React re-attaches in the browser, it compares the server’s bytes against the live tree it would build. A mismatch is the snapshot-versus-live gap from the start of this lesson, reported as an error. You’ll meet it properly when the course reaches server rendering.
DevTools
Every inspection reads the live tree, never the source. This is why the Elements panel can show a value the page source doesn’t, and why you trust the panel when debugging.
Third-party libraries
A tooltip, chart, or maps library needs a real DOM element to mount inside. You reach for the access surface to find that element, then hand it over. Classic ref-plus-effect glue.
The pattern underneath all five is the instinct this whole lesson exists to build: you reach for DOM primitives only when the React abstraction can’t cover a use case the platform owns, such as focus, measurement, scroll, or library integration. Everything else, you describe in JSX and let React own the tree. That is why this lesson filed every primitive under “rare reach, recognize it” rather than “daily tool.” You learned the substrate so the leaks make sense, not so you’d build on it directly.
One last piece turns this lesson’s model into a debugging habit you’ll use constantly. Back in the chapter “How a request becomes a page”, you met the Elements and Console panels and the $0 shortcut. Now you have the model to use them well.
The Elements panel is the live DOM tree, serialized back into readable HTML. With the snapshot-versus-live model in hand, you understand exactly why it can disagree with “View Source,” and you know which one to trust while debugging.
The real power is that you can run this lesson’s APIs against any node, live, from the Console. Here is the workflow:
Inspect the node. Right-click any element on the page → “Inspect”. DevTools opens the Elements panel with that node selected.
Read it as a tree. The selected node, its ancestors, and its children are the live tree, indented. Expand and collapse to navigate it.
Switch to the Console and probe the selected node with $0, which always refers to the last element you inspected. Type each line and read what the console echoes back:
$0.children// => HTMLCollection of the element's children (live)
$0.closest('form') // => the nearest ancestor <form>, or null
$0.value// => the live value, if $0 is an input
$0.nodeType// => 1, the code for an element node
Pin it for repeated use. Right-click the node → “Store as global variable”. DevTools assigns it to temp0, so you can keep probing it even after you inspect something else and $0 moves on.
This is the cheapest way to get a feel for the live tree: inspect a real element, then poke it with the exact methods you just learned and watch them answer.