The Trick Behind the Curtain
A good magic trick works because you never see the hands. React is a lot like that. You write a component, change some state, and the screen updates. It just works.
But "it just works" is not an explanation. And when something doesn't work — an element you can't find, a re-render you didn't expect — the magic turns into frustration.
So I've put together a walk through what React does behind the scenes, starting from the very bottom: the browser, the DOM, and plain JavaScript. No JSX at first. No build tools. Just two script tags and a lot of console.log. And the browser doing its job.
One question runs through all of it, so keep it in mind:
How much work does it take to update the DOM — and who does that work?
That question is the root of everything React is designed for. Let's pull back the curtain.
The DOM: A Tree in Memory
When the browser loads a page, it doesn't keep your HTML as text. It parses it and builds a tree of objects in memory — one node for every element, and one for every piece of text inside it. That tree is the Document Object Model, the DOM.
Take this small piece of HTML:
<div>
<h1>DOM Manipulation</h1>
<ul id="list">
<li>Item 1</li>
<li>Item 2</li>
</ul>
</div>The browser turns it into a tree: div at the top, h1 and ul as its children, two li nodes under the ul. Even the text "Item 1" becomes a node of its own.
The journey looks like this:
HTML → DOM → rendered page → user interactionAnd then it loops. The user clicks something, JavaScript changes the DOM, and the browser renders again. Everything React does lives inside that loop.
Is appendChild() JavaScript?
Here's a question that sounds silly until you think about it. Look at this code:
const listElement = document.getElementById('list');
const newListItem = document.createElement('li');
newListItem.textContent = 'New Item';
setTimeout(() => {
listElement.appendChild(newListItem);
}, 4000);Four seconds after the page loads, a new item appears in the list. Simple. But is appendChild() JavaScript?
You call it with JavaScript. But it isn't part of JavaScript.
appendChild() is defined by the DOM Standard, maintained by WHATWG — not by ECMAScript, the standard that defines the language. The browser implements it, usually in C++, and exposes it to your code as a method on every Node object.
The same goes for getElementById, createElement, and textContent. Even setTimeout isn't the language — it's a timer the browser gives you.
Here's the mental model I use:
- JavaScript is the language.
- The browser is the environment.
- Browser APIs (Web APIs) are what the environment offers:
fetch,localStorage,setTimeout, Geolocation, Canvas, WebSockets, and many more. - The DOM API is the part of those for the page itself.
Two Engines, One Page
Inside the browser, two engines do the heavy lifting. The JavaScript engine (V8 in Chrome, SpiderMonkey in Firefox, JavaScriptCore in Safari) runs your code and calls the DOM API. The rendering engine (Blink, Gecko, WebKit) takes the updated DOM, plus CSS, and paints pixels.
Your JavaScript never touches pixels. It changes the DOM, and the rendering engine does the rest. Remember that: every DOM change is potential work for the browser.
Where Your Script Tag Goes
Since the DOM is built while the browser parses the HTML, a script can only find elements that have already been parsed. That's why the classic advice is to put scripts at the end of <body>.
In <head>, you have two options:
deferdownloads the script in parallel and runs it after the HTML is fully parsed, in order. Safe for DOM code.asyncdownloads in parallel and runs the moment it arrives — possibly before the DOM is ready. Better for independent scripts like analytics.
How vs What
There are two ways to tell a computer what to do.
Imperative programming describes how: every step, in order, that gets you to the result. Declarative programming describes what: the result you want, and the underlying system works out the steps.
Here's the same task both ways — adding 25% tax to a list of prices:
// Imperative
const prices = [10, 20, 30];
const withTax = [];
for (let i = 0; i < prices.length; i++) {
withTax.push(prices[i] * 1.25);
}// Declarative
const withTax = prices.map((price) => price * 1.25);In the imperative version, I'm managing a counter, a condition, an increment, a new array, and a push. In the declarative one, I only say how one price changes. map() handles the looping.
But notice: map() is still implemented with a loop somewhere. Someone wrote those steps. Just not me.
That's the key insight:
A declarative system is always built on top of an imperative one.
When you use a declarative tool, you're using someone else's imperative code. You don't have to know how it works. You just work on top of it.
A Counter, Step by Step
Now let's bring that back to the DOM. Here's a classic counter:
<h3>You clicked <em id="count">1</em> times</h3>
<button onclick="setCount()">Click me</button>const countElement = document.getElementById('count');
function setCount() {
let count = Number(countElement.textContent);
count = count + 1;
countElement.textContent = count;
}Every click, I tell the browser exactly what to do:
- Find the element.
- Read the current value out of the page.
- Compute the next value.
- Write it back to the DOM.
And look where the state lives: in the DOM itself. The number on the screen is the data.
I can tidy this up by hiding the steps behind an object:
const countApp = {
getCount: () => Number(document.getElementById('count').textContent),
setCount: (val) => {
document.getElementById('count').textContent = val;
}
};
function addCount() {
let count = countApp.getCount();
if (count >= 5) {
countApp.setCount(0);
} else {
countApp.setCount(count + 1);
}
}addCount now reads almost like a description. But be honest about what this is: abstraction, not declarative code. countApp still finds, reads, and writes. And I still have to remember to call setCount every single time something changes.
Now Imagine a Real App
One counter took four manual steps. A real interface has hundreds of values that must stay in sync with the page.
You track every piece of state by hand. You update every place it shows up. Miss one, and the page shows stale data. The DOM and your data quietly drift apart, and every new feature adds more step-by-step updates.
What if we could skip all that and just describe the result?
UI = f(state)
Describe what the interface should look like for the current state, and let something else work out the DOM steps. That something is React.
A Short Detour: Recursion
Before we open React, we need one more tool. Because the DOM is a tree, and React's component tree is a tree too. And the natural way to walk a tree is recursion: a function that calls itself.
function countdown(n) {
if (n <= 0) {
console.log('Done!');
return;
}
console.log(n);
countdown(n - 1);
}
countdown(5); // 5, 4, 3, 2, 1, Done!Each call solves a smaller version of the same problem. The if at the top is the base case — the way out.
Without a base case, you get this:
function a() {
a();
}It calls itself forever. Well, not forever — until the call stack runs out of room and Chrome throws RangeError: Maximum call stack size exceeded. Every recursive function needs a moment where it asks: keep going, or stop?
Walking a Tree
To traverse a tree means to visit every node in a systematic way, from parent to child, until all of them have been visited.
Recursion does this naturally. One function handles one node, then calls itself for each of its children. A node with no children is the base case. Go all the way down first, then move on to the next sibling.
That's it. That's the fundamental idea we need: recursion through a tree.
One honest caveat: recursion isn't free. Every call takes space on the stack, so a very deep tree costs memory and time. We'll come back to that at the end.
Building Our Own Virtual DOM
Here's where it gets fun. Before touching React, let's build a tiny version of its core idea by hand.
First, describe the page as plain objects:
let markup = {
type: 'article',
children: [
{
type: 'h2',
children: [{ type: 'text', value: 'Counter' }]
},
{
type: 'p',
children: [
{ type: 'text', value: 'Counter ' },
{
type: 'strong',
children: [{ type: 'em', children: [{ type: 'text', value: '1' }] }]
},
{ type: 'text', value: ' times' }
]
},
{
type: 'button',
children: [{ type: 'text', value: 'Click me' }]
}
]
};This is a POJO — a plain old JavaScript object. Just keys and values. No methods. No browser.
Study it for a moment. It's a tree. Every node has a type and either children or, for text, a value. It mirrors the DOM exactly.
And it's declarative. It says what we want on screen, not how to put it there. Which also means that if you run this, nothing happens. Nothing appears. Our HTML has only an empty <main id="app">.
This is our virtual DOM. A fake DOM. A custom tree.
A Tiny Object vs a Real Node
Now grab the real element and look at it:
const main = document.getElementById('app');
console.dir(main);(console.log in Chrome shows the element as HTML. console.dir shows the object behind it.)
It's huge. Hundreds of properties and methods: innerHTML, appendChild, style, classList, parentNode, event handlers, and on and on.
Our POJO node has two keys.
That size difference is the whole reason a virtual DOM makes sense. Plain objects are cheap to create, cheap to compare, and cheap to throw away. Real DOM nodes are not.
Turning Objects into DOM Nodes
The object is declarative. To show it, we need some imperative code. Here's the function — designed to call itself:
function addElements(pojoElement, parentDOMNode) {
let newDOMNode =
pojoElement.type === 'text'
? document.createTextNode(pojoElement.value)
: document.createElement(pojoElement.type);
if (pojoElement.children) {
pojoElement.children.forEach((child) => {
addElements(child, newDOMNode);
});
}
parentDOMNode.appendChild(newDOMNode);
}
addElements(markup, main);Three steps:
- Pick the kind of node. If our fake node is text, create a real text node with its value. Otherwise, create a real element of the same type.
divmakes adiv.h2makes anh2. - Recurse into the children. For each child, call
addElementsagain — and pass the new DOM node as the parent. That's the trick:newDOMNodebecomes the parent for the next level down. - Attach.
appendChildputs the new node into its parent. This is the real DOM update.
There's no risk of infinite recursion here. Our object isn't infinite. Text nodes have no children, and that's where every branch stops.
Following the Recursion
Let's stop for a second and trace what actually happens:
addElements(article, main)
create <article>
addElements(h2, article)
create <h2>
addElements("Counter", h2) → text node
append <h2> to <article>
addElements(p, article) …
addElements(button, article) …
append <article> to main ← the page changes hereNotice the order. appendChild runs after the children are done. So the whole article is built off-screen, and the visible page changes exactly once — at the very end, when the article is appended to main.
Want proof it works? Add a sibling to the markup object:
{
type: 'h3',
children: [{ type: 'text', value: 'This is working!' }]
},It shows up. No other code changes.
That's the whole idea: you describe the tree, and one recursive function does the imperative work. Now let's see how React does the same thing.
React Elements: Same Idea, Better Tools
To use React without any build step, we add two script tags:
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>Why two?
- React is the core. It creates React elements and runs components. It knows nothing about the browser.
- ReactDOM is the renderer. It turns React elements into real DOM nodes and keeps them updated. It's the part that talks to the DOM API.
React describes. ReactDOM puts it on the page. That split is also why React Native can exist: same core, different renderer.
A quick note if you try this at home: these UMD builds work for React 18. React 19 no longer ships them, so real projects install React from npm and use a bundler.
Creating Elements
Here's our counter again, this time in React:
function App() {
return React.createElement(
'article',
null,
React.createElement('h2', null, 'Counter'),
React.createElement('p', null, 'You clicked 1 times'),
React.createElement('button', null, 'Click me')
);
}createElement(type, props, ...children) takes three things:
type— what to create:"article","h2","button".props— attributes likeclassNameoronClick.nullwhen there are none.children— plain text, or other React elements. That's how they nest into a tree.
A React Element Is Just an Object
So what does createElement actually return? Log App() and you'll see something like this (simplified):
{
type: 'article',
props: {
children: [
{ type: 'h2', props: { children: 'Counter' } },
{ type: 'p', props: { children: 'You clicked 1 times' } },
{ type: 'button', props: { children: 'Click me' } }
]
}
// + $$typeof, key, ref: React's own bookkeeping
}Look familiar?
| Our markup | React element |
|---|---|
type |
type |
children |
props.children |
{ type: "text", value: "…" } |
a plain string |
It's the same idea we built by hand. A tree of plain objects describing what the UI should be. React calls this tree the virtual DOM.
A Type Can Be a Function
Here's the twist. The type doesn't have to be a string.
const root = ReactDOM.createRoot(document.getElementById('app'));
root.render(React.createElement(App));Look closely. We pass App, not App(). We never call our own component.
- A string type (
"button") means: create that DOM element. - A function type (
App) means: this is a component. Call it to get more React elements out of it.
In JavaScript, functions are first-class values. We can pass them around like anything else. So we hand App to React, and React decides when to call it.
A tree of React elements is a tree of simple objects whose types are either strings (make a DOM node) or functions (call me for more elements). That's the whole model.
And JSX?
In practice, nobody writes createElement by hand. You write JSX:
function App() {
return (
<article>
<h2>Counter</h2>
<p>You clicked 1 times</p>
<button>Click me</button>
</article>
);
}But browsers can't run JSX. A compiler like Babel or TypeScript turns it into function calls first. Since React 17, those calls go to a jsx() helper from react/jsx-runtime rather than React.createElement — but the result is the same kind of object.
JSX is just nicer syntax for building the tree. I still think createElement is the clearest way to see what's really happening. And you'll meet it in older codebases.
What render() Really Does
In React, render means one thing: take the declarative description of the UI and make the real DOM match it.
const root = ReactDOM.createRoot(document.getElementById('app'));
root.render(React.createElement(App));createRoot tells ReactDOM which real DOM node it owns. Then render kicks off the work:
- We hand React one element whose type is the
Appfunction. - React calls
App()and gets more elements back. If any of those were functions too, it would call them as well — the same recursion we wrote by hand. - The result is the element tree: the virtual DOM, in memory.
- ReactDOM creates real DOM nodes to match, inside
#app.
We never describe how to create a DOM node. That's React's imperative code underneath. We just work on top of it.
Two Trees, Two Meanings
This distinction is worth getting really clear on:
- React elements are what you want the DOM to look like. Light objects in memory.
- DOM elements are what the DOM actually looks like. What the user sees and uses on screen right now.
Creating React elements doesn't touch the page at all. Only rendering does. And the user only ever interacts with the real DOM — never with the virtual one.
Why Can't I Find My Article?
Here's a demo that makes this concrete. (For this one, I removed the old hand-written <article> from the HTML, so the only article on the page is React's.)
root.render(React.createElement(App));
const found = document.getElementsByTagName('article');
console.log(found.item(0)); // null
setTimeout(() => {
console.log(found.item(0)); // <article>…</article>
}, 2000);Right after render, the article doesn't exist. Two seconds later, it does.
Why? Because render doesn't build the DOM on the spot. It schedules the work and returns immediately. Our next lines run first, before React has created a single DOM node. React does its work asynchronously, so you have to wait for it before reading the DOM.
Two gotchas worth knowing:
getElementsByTagNamereturns a live collection. If you log the collection itself and expand it later in DevTools, it may already look filled.item(0)tells you the honest answer at that moment.- The two seconds are just a generous wait. In a real component, you'd read the DOM in
useEffector through a ref — both run after React has updated the DOM.
Update Only What Changed
Back to our question. Updating the DOM is expensive. So when the count goes from 1 to 2, do we rebuild the whole article?
No. We only update the parts that changed.
When state changes, React calls your components again and gets a new tree of elements. Then it compares the new tree with the previous one. Two cheap object trees — not the real DOM.
Previous New
article article
h2 "Counter" h2 "Counter"
p "You clicked 1 times" p "You clicked 2 times" ← changed
button "Click me" button "Click me"Only the text inside the <p> changed. So React makes exactly one DOM update and leaves the article, the heading, and the button alone. This comparing step is called reconciliation, or diffing.
One correction to a phrase you'll hear a lot: React doesn't compare the virtual DOM against the real DOM. It compares the new virtual tree with the old virtual tree, then applies the difference to the real DOM. That's exactly why the trees are made of plain objects. Comparing two POJOs is cheap. Reading hundreds of properties off real DOM nodes is not.
When the Tree Gets Big
Walking a large tree on every change still costs time and memory. Remember the recursion caveat? Two techniques keep it cheap:
- Memoization — cache a result and reuse it when the inputs haven't changed. In React:
React.memofor components,useMemoanduseCallbackinside them. The React Compiler can also add this for you automatically. - Virtualization — render only what's visible. A list with thousands of rows only draws the ones on screen. Libraries like
react-windowand TanStack Virtual do this for you.
The Hands Behind the Trick
Let's put the whole story together:
- The browser turns HTML into the DOM — a tree of heavy objects.
- Updating it step by step, imperatively, works for a counter. It doesn't scale to an app.
- React elements describe the UI as a tree of light, plain objects.
- React walks that tree recursively, calling your components, and ReactDOM makes the real DOM match.
- When something changes, React compares the new tree with the old one and touches only what changed.
None of this is magic. It's the same addElements function we wrote in a dozen lines — plus years of other people's careful, imperative work on top.
That's what a declarative tool really is. Someone else's imperative code, so you don't have to write it.
Once you've seen the hands, the trick doesn't get less impressive. You just stop being surprised by it.
You describe what. React does how.
Want this as a talk or a hands-on session for your team? I give presentations on React, the DOM, and frontend architecture — get in touch.