← Writing
The Editorial · Engineering

How React actually works — traced, click by click

Issue 011Jul 21, 202616 min read

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.

the same counter, by hand, no ReactHTML + JS
<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.

the same counter, in ReactJSX
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:

what Babel actually emitsJSX → JS
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:

console.log(Counter())console
{
  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 follows

Step 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:

the fiber tree after mounttext
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:

the real DOM, right after mountHTML
<div>
  <p>hello world 0</p>
  <button>-</button>
  <button>+</button>
</div>
render phase · pure, interruptiblecommit1state change2render3reconcile4commit
Fig 1Every state change runs this same four-step pipeline — render and reconcile are pure JS; commit is the one step that touches the real DOM.AI-generated figure

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():

console.log(Counter()) — second callconsole
{
  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:

the only DOM mutation for this whole clickJS
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:

the shape of a fiberTypeScript (simplified)
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:

the text fiber, right after commitconsole
{
  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:

the recursive way — and why it can't pauseJS
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.

Counter's own hook, after the clickconsole
{
  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.

call order · every renderfiber's hook list1 · useState(name)slot 02 · useEffect(fn)slot 13 · useState(count)slot 2matched by position only —a conditional hook shifts every slot after it
Fig 2Each hook call maps to a fixed slot on the fiber by position alone — the same rule Counter's single useState follows above.AI-generated figure
beforeafterhello world 0+hello world 1+one clickseven steps · one DOM write77 · click + again → re-enters at 2, buffers already swappedbefore any click0compileJSX → jsx() calls · Babel, build time1mountelement tree → fiber tree → real DOMthe click2setCount(1)queue { action: 1 } · touch nothingrender phase · pure JS · pausable3render againCounter() re-runs · count = 14reconcile5 fibers reused · 1 marked Updatecommit phase · touches the DOM5committextNode.nodeValue = "1"aftermath6paint & swapcurrent ⇄ work-in-progress
Fig 3The whole trace on one card — step 5 is the only one the screen ever feels, and the next click doesn't start over: it re-enters at step 2 with the buffers already swapped.AI-generated figure
All issues