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.
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.
The parser builds the tree once, at load. After that, three sources write to that same tree:
<details> flips its open state.element.textContent = 'Saved' and that node’s text changes at once.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
Live DOM tree
what DevTools shows
a property, rewritten by autofill
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 .checkedHTMLAnchorElement adds .hrefHTMLImageElement adds .src and .naturalWidthHTMLButtonElement, HTMLFormElement, and so onNode
addsElement
+ addsHTMLElement
+ addsHTMLInputElement
+ addsNodes — recognize, don’t memorize
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.
parentNodenodeTypegetAttributeclassListdatasetstyletabIndex.value.checkedTo 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?”
| 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 |
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.
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 elementchildren: the element childrenfirstElementChild / lastElementChildnextElementSibling / previousElementSiblingThe node-flavored family sees everything, text nodes included:
parentNodechildNodesfirstChild / lastChildnextSibling / previousSiblingThe 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);<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.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.
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.
const list = document.getElementById('list');
for (const item of [...list.children]) { if (item.classList.contains('done')) { item.remove(); }}Visits every match. Spreading into a real array snapshots the elements before you touch anything, so removals no longer shift what you iterate. The whole fix is one pair of brackets.
Scrub through the buggy loop one tick at a time to watch the index drift.
Start, i = 0. The live collection is [A, B, C]. Index 0 is A, which is .done, so the loop calls A.remove().
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.
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:
Inspect the node. Right-click any element → “Inspect”. DevTools opens the Elements panel with that node selected.
Read it as a tree. The node, its ancestors, and its children appear indented; expand and collapse to navigate.
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 nodePin 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.
The canonical overview of the DOM as a logical tree of node objects that scripts read and rewrite.
An interactive tour of the node types (element, text, comment, document) with a live tree explorer.
The four access methods plus closest and matches, with the live-vs-static collection distinction worked through.
Why children is live and querySelectorAll is static, the trap behind the iterate-while-mutating bug.