How React actually works — traced, click by click
Not the whiteboard summary — the literal thing that happens when you press a button. Ground up, on the smallest possible screen: hello world {count} with a + and a − button, traced object by object down to the one real DOM write React actually makes.
The issue
Forget the six-term interview cram sheet for a second. Here's the whole thing traced by hand, on the smallest screen that still has everything worth seeing on it: hello world {count}, a + button, and a − button.
<p>hello world <span id="count">0</span></p>
<button id="minus">-</button>
<button id="plus">+</button>
<script>
let count = 0;
const countEl = document.getElementById("count");
document.getElementById("plus").onclick = () => {
count = count + 1;
countEl.textContent = count; // a human decided this is the only thing that changed
};
document.getElementById("minus").onclick = () => {
count = count - 1;
countEl.textContent = count;
};
</script>This is correct, and honestly cheaper than what React is about to do for the same click — because a person looked at this exact app and decided, once, that only the number ever needs to change. React's entire job is to make that decision automatically and correctly, every time, for a UI too big for a person to hold in their head. Here's exactly how, for the smallest version of the problem there is.
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>hello world {count}</p>
<button onClick={() => setCount(count - 1)}>-</button>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}Step 0 — your JSX is not HTML, it's two function calls
Babel turns that JSX into calls to jsx() and jsxs() before any of this reaches a browser — no tag parsing at runtime. By the time React sees <Counter />, it's already plain JavaScript:
import { jsx, jsxs } from "react/jsx-runtime";
function Counter() {
const [count, setCount] = useState(0);
return jsxs("div", {
children: [
jsxs("p", { children: ["hello world ", count] }),
jsx("button", { onClick: () => setCount(count - 1), children: "-" }),
jsx("button", { onClick: () => setCount(count + 1), children: "+" }),
],
});
}Call Counter() yourself, count still 0, and log what comes back — it's a plain object. Nothing on screen, nothing rendered, nothing React hasn't already forgotten by the time you read this:
{
type: "div",
props: {
children: [
{ type: "p", props: { children: ["hello world ", 0] } },
{ type: "button", props: { onClick: [Function], children: "-" } },
{ type: "button", props: { onClick: [Function], children: "+" } }
]
}
}
// real React elements also carry a $$typeof: Symbol(react.element) tag —
// dropped here, it doesn't matter to anything that followsStep 1 — first paint: element tree becomes fiber tree becomes real DOM
ReactDOM.createRoot(root).render(<Counter />) calls Counter() once, gets the object above, then does something you never asked for: for every one of those objects it builds a second object — a fiber — and links them with child / sibling / return pointers instead of the children array you wrote. This fiber tree, not the object above, is what React keeps around between renders:
HostRoot
└─ Counter fiber (memoizedState → Hook{ memoizedState: 0, next: null })
└─ div fiber (stateNode → the real <div>)
├─ p fiber (first child of div, stateNode → the real <p>)
│ ├─ text fiber "hello world " (first child of p)
│ └─ text fiber "0" (sibling of the fiber above — same parent, same depth)
├─ button fiber "-" (sibling of p — same depth, stateNode → real <button>)
└─ button fiber "+" (sibling of button "-" — same depth)Then commit: React walks that tree once and does the actual DOM work — create, append, set text — synchronously, in one pass. What lands in the browser:
<div>
<p>hello world 0</p>
<button>-</button>
<button>+</button>
</div>Step 2 — you click +: what setCount actually does
count was 0 in this render, so the closure makes this call literally setCount(1) — a plain number, not a function. setCount does exactly two things, synchronously, and neither one touches the screen:
Step 3 — render runs again: React calls Counter() a second time
Same function, same fiber — but useState() now finds a pending update sitting in the queue on hook slot 0: 0, plus the queued action 1, gives 1. This time, count really is 1 inside Counter():
{
type: "div",
props: {
children: [
{ type: "p", props: { children: ["hello world ", 1] } },
{ type: "button", props: { onClick: [Function], children: "-" } },
{ type: "button", props: { onClick: [Function], children: "+" } }
]
}
}Notice what's identical to the first object: the div, the p, both buttons — same type, same shape, same position. The only value anywhere in this tree that's different is one number, three levels down.
Step 4 — reconciliation: comparing old vs new, fiber by fiber
Reconciliation never diffs HTML strings — it walks the old fiber tree and the new element tree side by side, one level at a time, and asks one question at each position: same type, same slot? For this click:
Six fibers were involved in this render. Five come out the other side with no flag at all — reused exactly as they were. One gets marked Update: the text fiber holding the number.
Step 5 — commit: the one real DOM write
Commit walks the tree once more, but only visits fibers carrying a flag. The div, the p element itself, both buttons — skipped entirely, nothing about them changed. The entire commit for this click is one line:
textNode.nodeValue = "1";That's the same single mutation the hand-written version made with countEl.textContent = count. React didn't find a shortcut — it arrived at the identical minimal update, automatically, by comparing two trees, and it keeps arriving at the right minimal update even when this counter is nested twenty components deep inside a page too big to track by hand.
Step 6 — paint, then swap the buffers
The browser paints the new "1". Then React does one bookkeeping step: the tree it just built — work-in-progress — becomes current. The old current isn't discarded; it becomes the next work-in-progress buffer, reused in place. That's the alternate pointer on every fiber: each one points at its own other-buffer twin.
Fiber, from the ground up
Strip away everything React-specific and a fiber is just a JS object with a fixed set of fields, one per element or component instance. The shape, simplified to what actually mattered above:
type Fiber = {
type: string | Function | null; // "div", "p", "button", Counter itself — null for host text fibers
stateNode: HTMLElement | Text | null; // the real DOM node this fiber owns
memoizedProps: any; // props from the last committed render
pendingProps: any; // props from the render in progress
memoizedState: Hook | null; // linked list of hooks — component fibers only
child: Fiber | null; // first child
sibling: Fiber | null; // next sibling
return: Fiber | null; // parent
alternate: Fiber | null; // this same node's other-buffer twin
flags: number; // Update, Placement, Deletion, ...
};And that same shape, filled in for real, for the fiber that changed in Step 5:
{
type: null, // host text fibers carry no element type
stateNode: textNode, // the actual DOM Text node showing "1"
memoizedProps: "1",
pendingProps: "1",
memoizedState: null,
child: null,
sibling: null,
return: pFiber,
alternate: previousTextFiber, // the twin that held "0"
flags: 0, // cleared — the work is done
}The whole trick comes down to one swap: replace the children array with three pointers — child, sibling, return. Here's exactly why that swap matters, starting with the "obvious" way to walk a tree, written by hand:
function renderElement(element) {
const node = createDomNode(element);
for (const child of element.props.children) {
node.appendChild(renderElement(child)); // calls itself
}
return node;
}
// mid-walk, the real call stack looks like this:
renderElement(divElement) // paused mid-loop, on child 0 (the <p>)
renderElement(pElement) // paused mid-loop, on child 0 ("hello world ")
renderElement(textElement) // currently running
// the moment pElement's call returns, "I was on child 0" is gone —
// it only ever existed as that one, now-dead, function call.So a plain recursive walk has exactly one mode: run the whole tree, blocking, start to finish. There's no partial state to save, so there's nothing to pause. child/sibling/return fix this by moving "where am I" off the call stack and onto the fibers themselves, as one plain variable — a pointer to whichever fiber you're on right now, sitting in memory like any other object. Pausing is just: stop the loop. Resuming is: read that same variable, keep going. Nothing to lose, because nothing depended on a live function call in the first place. Before Fiber (React ≤ 15), rendering really was one uninterruptible recursive call — the entire reason Fiber exists.
The hook, from the ground up
Counter has exactly one hook, so its fiber's memoizedState points at exactly one node. Call its position slot 0 — "slot" just means where a hook call sits in this list, counting from the top of the function body. It's not a name React reads off your code; count and setCount don't exist anywhere on the fiber. All React sees is: first hook call this render, so read/write whatever's in slot 0.
{
memoizedState: 1, // the actual `count` value returned to your component
queue: { pending: null }, // pending updates — empty right after a render processes them
next: null, // the next hook this component called — none here
}If Counter called a second useState, or a useEffect, that hook would sit at .next — slot 1, chained after slot 0 — in exactly the order the calls appear in the function body. React finds each hook by walking this list positionally on every render, never by name or argument, which is the entire reason a hook can never live inside an if or a loop: skip a call on one render, and every hook after it shifts into the wrong slot — silently, no error, just wrong values.