Skip to content
Chapter 14Lesson 1

The DOM as a live tree of typed nodes

An introduction to the DOM, the browser's live in-memory tree of typed nodes that JavaScript reads and rewrites and React builds on.

You sign in to an app and the email field autofills with ada@example.com. You open DevTools, find the <input> in the Elements panel, and it reads value="ada@example.com", exactly what’s on screen. Then you hit “View Source”, scroll to the same input, and the markup says <input value="">. Empty. One element, two answers. Which is lying?

Neither. “View Source” shows the original bytes the server sent, a photograph of the HTML taken the moment the page loaded. The Elements panel shows the live tree the parser built from those bytes, which autofill, your keystrokes, and every script on the page have been mutating ever since. The source is frozen; the tree is alive.

That live tree is the DOM : a data structure of typed objects, sitting in memory, that JavaScript reads and rewrites. Read it correctly and you can find a node, walk the tree, and predict the classic bug that catches anyone who loops over it while changing it. This is the substrate React sits on, surfacing later in refs, portals, and hydration errors.

Why the source HTML and the live DOM diverge

Section titled “Why the source HTML and the live DOM diverge”

The parser builds the tree once, at load. After that, three sources write to that same tree:

  • Browser features. Autofill drops your email into an input; toggling a <details> flips its open state.
  • Your own JavaScript. Set element.textContent = 'Saved' and that node’s text changes at once.
  • Every React render. Each render reconciles against this tree and writes the differences into it.

All three write to the one live tree. None touch the original bytes: the server sent them, the parser consumed them, and nothing kept them around to edit.

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 it forever after.

Every node is a typed object: the Node hierarchy

Section titled “Every node is a typed object: the Node hierarchy”

The DOM is a tree of objects, but 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 read a type in your editor and tell at once what you can and can’t do with the node it points at.

At the base sits Node, the abstract base class every tree member inherits from. It carries the members about being in a tree: parentNode, childNodes, nodeType. Everything in the tree has these, because everything in the tree is a node.

One step up is Element, a Node with a tag and attributes. This is where getAttribute, classList, and querySelector live. It covers HTML and SVG, so it knows you have a tag but not which one.

Up again is HTMLElement, an HTML element specifically. It adds the members every HTML element shares: style, dataset, hidden, tabIndex.

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

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:

const focusEmail = (field: HTMLInputElement) => {
field.focus();
field.select();
};
const readValue = (node: Element) => {
return node.value;
};

The second function doesn’t type-check. Element knows the node has a tag, but .value belongs to HTMLInputElement, further down the chain. Type the parameter as HTMLInputElement and it compiles. Read the type, place it on the ladder, and you see which members are in reach.

This is also why useRef<HTMLInputElement>(null) works in React: the generic names the exact level, so the ref hands you a node with .value on it.

Not every node is an element. A handful of other types round out the tree, and you mostly need to recognize them when they surface in a debugger or while you walk the tree:

  • Text: the run of characters between tags. 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: an off-tree container you build nodes inside before attaching them; also the substrate behind React Portals.
  • DocumentType: the <!DOCTYPE html> declaration.

Recognize these rather than memorize their APIs. The exercise below drills the one thing that sticks: which level first introduces each member.

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.

Node Any tree member
Element Has a tag
HTMLElement An HTML element
HTMLInputElement A specific tag
parentNode
nodeType
getAttribute
classList
dataset
style
tabIndex
.value
.checked

To get a reference to one node in the tree, four methods cover it, and the question you’re asking picks which.

document.getElementById(id) matches one element by its id attribute and returns it 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 whole descendant subtree of the element you call them on, or of document. querySelector returns the first match or null; querySelectorAll returns a NodeList of every match. When an id isn’t enough, a selector almost always is.

element.closest(selector) walks the other way, up the ancestor chain from the element itself, and returns the nearest match or null. Given a clicked element, it finds the row or form it belongs to.

element.matches(selector) returns a boolean for whether this element matches the selector. Both closest and matches earn their keep later, in event delegation.

// <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 one node, so getElementById is the sharpest tool.

// <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>.

// <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');

From a link, closest walks up to the first <li> wrapping it, the move you reach for 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');

No node to find here, just a yes/no about one you have: matches answers “is this .active?”

1 / 1
You want…Method
an exact element by idgetElementById
a CSS match somewhere in the subtreequerySelector / querySelectorAll
the nearest matching ancestorclosest
a yes/no on one elementmatches

These four are an escape hatch, not a daily tool. In React you declare what the UI is in JSX and let React own the tree, rather than peppering components with querySelector calls. They surface only when you step outside React to the raw platform: inside an effect, a ref callback, or glue code for a third-party library. Learn them so you recognize them when the hatch opens.

Section titled “Navigating the tree: element-flavored vs node-flavored properties”

From one node you often want a neighbor: its parent, first child, or next sibling. The DOM offers two parallel families of properties for this.

Reach for the element-flavored family by default. It walks element to element, skipping Text and Comment nodes, so “the first child” means the first element:

  • parentElement: the parent, if it’s an element
  • children: the element children
  • firstElementChild / lastElementChild
  • nextElementSibling / previousElementSibling

The node-flavored family sees everything, text nodes included:

  • parentNode
  • childNodes
  • firstChild / lastChild
  • nextSibling / previousSibling

The difference bites because the whitespace between two tags is a Text node. 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);

Default to the *Element* members for the element-to-element walk you almost always want; reach for the node-flavored family only when you need to touch text or comment nodes.

Live vs. static collections, and the index-drift bug

Section titled “Live vs. static collections, and the index-drift bug”

element.children (an HTMLCollection) and element.childNodes (a NodeList, including text nodes) are live: a window onto the tree as it is right now, so adding or removing a child changes their contents and .length in the same instant. element.querySelectorAll(...) also returns a NodeList, but it is static: a snapshot frozen at the moment you called it, unaffected by later changes. That split is harmless until you iterate a live collection while changing it:

const list = document.getElementById('list');
for (let i = 0; i < list.children.length; i++) {
if (list.children[i].classList.contains('done')) {
list.children[i].remove();
}
}

Skips elements. Each removal shrinks the live list.children at once, shifting every later element down by one. The item that drops into index i never gets checked, because the loop has already advanced to i + 1. Two adjacent .done items, and the second survives.

Scrub through the buggy loop one tick at a time to watch the index drift.

i = 0 list.children.length = 3 A is .done → remove it
[0]
A .done
i = 0
[1]
B .done
[2]
C

Start, i = 0. The live collection is [A, B, C]. Index 0 is A, which is .done, so the loop calls A.remove().

i = 1 list.children.length = 2 i jumped past B
A removed
shifted ↓ the rest
[0]
B .done
[1]
C
i = 1

After removing A, i = 1. Everyone shifted down: B is now index 0, C index 1. But i already advanced to 1, so the loop reads C and never visits B.

i = 2 list.children.length = 2 i ≥ length → stop
A removed
shifted ↓ the rest
[0]
B .done
[1]
C

After checking C, i = 2. i is past the shrunken length of 2, so the loop stops. Final result: [B, C]B should have been removed, but the drift skipped it.

The habit: snapshot before you mutate. [...element.children] or Array.from(element.children) gives you an array immune to tree changes, with map, filter, and forEach to boot. A querySelectorAll result won’t drift, but spread it too when you want those methods, since a NodeList offers only forEach.

If React builds and owns the tree, when do you touch it? In five places, none of them daily, each one the abstraction thinning out to show the platform underneath.

Refs to imperative DOM

A ref hands you the actual node for what JSX can’t express: focus an input, scroll it into view, measure it, or pass it to a library.

Portals

React can render a component’s output elsewhere in the tree, such as a modal escaping its parent’s overflow.

Hydration

When the server’s HTML and the browser’s live tree disagree, React reports the gap as an error.

DevTools

Every inspection reads the live tree, not the source, which is why the Elements panel can show a value the page source doesn’t.

Third-party libraries

A tooltip, chart, or maps library needs a real DOM element to mount inside, so you find that element and hand it over.

The instinct under all five is the one this lesson builds: reach for DOM primitives only when React can’t cover a use case the platform owns. Everything else, you describe in JSX and let React own the tree.

You met the Elements and Console panels and $0 back in “DevTools tour”; now you have the model to use them.

The Elements panel is the live DOM tree, serialized back to readable HTML, which is why it disagrees with “View Source”: the latter records only what the server first delivered, before autofill, JavaScript, and hydration moved the tree. When you debug, trust the panel. You can run this lesson’s APIs against any node, live, from the Console:

  1. Inspect the node. Right-click any element → “Inspect”. DevTools opens the Elements panel with that node selected.

  2. Read it as a tree. The node, its ancestors, and its children appear indented; expand and collapse to navigate.

  3. Switch to the Console and probe the node with $0, which refers to the last element you inspected. Type each line and read what 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
  4. Pin it for reuse. Right-click the node → “Store as global variable”. DevTools assigns it to temp0, so you keep a handle even after $0 moves on.