No magic, no syntax the browser understands. Underneath every component is a plain JavaScript object and a loop that turns those objects into pixels. We build the whole idea from scratch — and show exactly why vanilla JavaScript ran out of road.
It isn't the verbosity. It's that you own the job of keeping the in lockstep with your state — by hand, forever, per feature. Build a counter the honest way and the hard part stays hidden; add a second feature and it surfaces.
Here's a counter built the honest, way. It works. Now toggle features and watch the sync code grow.
Look closely at what the imperative version actually is. Half of it isn't logic — it's bookkeeping. For every piece of state that you hand-write, the path that carries a change into the DOM looks like this:
You aren't describing the interface. You're describing the transitions between interfaces, one edge at a time.
And the transitions are where the bugs live. A counter has one state, but a screen with a toggle, a filter, and a modal has a combinatorial number of paths between states — and you are personally responsible for every one. Miss the badge when the count hits zero and the screen now disagrees with the truth. Nothing threw. Nothing crashed. The DOM has just quietly drifted out of sync with your state, and it stays wrong until someone notices.
And those paths multiply faster than you'd guess. Every imperative edit silently assumes a starting state — el.classList.add('active') is only correct if the element exists, wasn't already active, and nothing else is about to remove it. The edit encodes a path, and a path is valid from only one starting point. The instant your app has more than one possible starting state — and it does, the moment you add async, input, or partial failure — you need a different correct path from each. Declarative collapses that fan-out into a single function: one description, correct for every state.
Fig.On the left, each starting state demands its own hand-authored transition, and the tangle grows faster than the states do. On the right, every state passes through the same function. You stop maintaining a map of routes and maintain a single description of the territory.
You are the runtime. The function that maps state to pixels already exists in the imperative version — but it lives in your head, and you execute it by hand, every time, at every edge. Vanilla JS never ran out of power; it ran out of you.
What if you never touched the DOM? You write one pure function — given the state, here's what the screen should be — and hand the mutation work to someone else. That's the pivot.
Change the state. The imperative mind asks "which nodes do I edit?"
The declarative mind just re-states the whole picture as a function of count.
This function is called again from scratch on every change. Its output is a fresh description, not a list of edits. Re-render = re-describe.
Here is the entire move, in one sentence: stop writing the edits, start writing the answer.
Imperative code is a list of verbs — create, append, setAttribute, remove. It describes a journey: how to drag the DOM from whatever it is now to what it ought to be. Declarative code is a single noun — given this state, here is what the screen is. You stop choreographing the transition and start naming the destination. The route from here to there stops being your job and becomes the runtime's.
You already trust this exact trade everywhere else you work. You don't open a cursor and walk rows mutating them
one by one — you write SELECT … WHERE … and let the planner choose the access path. You don't tell the furnace
when to switch on — you set a target temperature and let the thermostat converge. UI = f(state) is that same
bargain, struck for the screen: declare the result, surrender the steps.
Fig.The left side is a recipe — every arrow is a verb, and every verb assumes the one before it succeeded. The right side is an equation. The contrast in busyness is the whole point: one shape has steps you can get wrong; the other has a result you can only get right.
Here is the part that one sentence glosses over, and it's the most important part. When you write UI = f(state), the create and append and setAttribute calls don't disappear. They still run. Someone still has to walk the DOM from where it is to where your function says it should be. You just stopped being that someone.
In React, f returns a description — a tree of plain objects, not actual DOM. Then the takes your new description, compares it against the previous one, computes the smallest set of mutations that closes the gap, and emits exactly those imperative operations. The verbs you stopped writing reappear at the bottom of the pipeline — authored by the runtime instead of by you.
Declarative isn't the absence of imperative steps. It's the relocation of them — from your code into an engine you've agreed to trust.
Fig.The same verbs from the first diagram reappear at the bottom — but now the runtime emits them, computed fresh from a diff. You wrote a description (teal); the engine inside the dashed box turned it back into a journey (amber). The abstraction didn't delete the imperative work — it moved it somewhere you no longer have to look.
There are two ways to keep a
screen in sync with your data. The imperative way — the jQuery way — is to hold references to DOM nodes and hand-write
each change: appendChild, textContent = …, removeChild. You own every transition, and
that bookkeeping is where bugs breed. The declarative way stops managing transitions altogether: on every change you
describe what the UI should be right now, as a pure function of state — UI = f(state) — and hold a handle
to nothing. Done naively against the real DOM that would be far too slow. React's trick is to let you write in that
all-at-once style while diffing your new description against the last one and touching only what actually changed.
The simple model and the cheap implementation — no tradeoff.
SELECT … WHERE, a thermostat's target
temperature. Naming the screen's version of it is the entire leap; everything after this is just how React makes it cheap.To diff "what should be" against "what is," the runtime needs a representation of your UI it can mint by the thousand and throw away a millisecond later. A real node is far too heavy for that — it has identity, lifecycle, hidden state. So React reaches for the cheapest thing that can still describe a node.
Hover the element or the object keys — same thing, different clothes.
console.log'd.
$$typeof is a Symbol React stamps on things it made — and it's a Symbol on purpose:
Symbols don't survive JSON.parse, so a JSON payload from a server can never forge an element React
would render. It's an XSS guard, not decoration.Notice what just happened to the word "view." A DOM node is a thing: it has identity, it persists, it hoards hidden state — scroll position, focus, a half-typed input — and the only way to change it is to reach in and mutate it. A React element is a value: a plain, frozen description with no identity and no lifecycle, cheap enough to mint a few thousand of on a single keystroke and discard a millisecond later.
That swap — from a mutable thing you hold to an immutable value you produce — is the quiet center of the
whole paradigm. Values can be compared, so you can diff them. Values can be kept, so you can hang on to the previous one.
Values can be recomputed from scratch, so "what should the screen be?" finally has an answer that doesn't depend on
what it was. You can do none of that with a live <div>; you can do all of it with
{ type, props, children }. The view stopped being infrastructure you manage and became data you
compute.
A plain object you can build a
whole tree of per render and throw away — cheaply enough that re-describing everything becomes a viable strategy,
impossible with live, stateful DOM nodes. That disposable sketch the runtime diffs before touching the page is the
; each node is just a
of { type, props, children }. The view stopped being infrastructure you manage and became data you
compute.
UI = f(state)
and never reason about transitions, while the runtime makes "fast enough" the default instead of something you
hand-tune. Trading peak performance for a model you can actually keep correct.The persistent myth: "I'm writing HTML in JavaScript." You're not. is syntax a rewrites into ordinary calls — which return the plain objects from 03.
Edit the JSX and watch it flow through the real pipeline, or press play to walk the four stages: your syntax → createElement calls → a plain object → rendered DOM.
JSX feels like magic because it looks like a template language —
Handlebars or ERB smuggled into JavaScript. It is nothing of the sort. There is no template engine, no interpolation
runtime, no special evaluation context. Every JSX expression is just a JavaScript expression that happens
to evaluate to one of those plain objects from movement 03. <App/> is a function call in a costume.
That's why there's no template syntax to memorize for loops or conditionals — you reach for map and
&& and ternaries, because you never left JavaScript. A component isn't a template that gets
"rendered"; it's a function that takes props and returns a value. Once the costume comes off — JSX →
createElement → object — the question stops being "how do I do X in JSX?" and becomes "how do I
compute this value in JS?" — which you already know how to answer.
createElement("h1", null, "Hi") is a
recipe that returns { type:"h1", props:{ children:"Hi" } }. JSX → call → object."It compiles away" has a price: browsers run JS, not JSX, so something must parse your source, swap every tag for a function call, and print plain JS before anything ships. That's Babel/SWC/tsc — an honest text transformation. The sugar is free to write; it is never free to run.
React.createElement(…) — because it's the clearest. Since React 17 the default is the
automatic runtime: the compiler imports jsx/jsxs from react/jsx-runtime and
calls those instead, so you no longer need React in scope. Different function name, identical idea —
JSX still becomes a call that returns a plain object.Re-render hands React a brand-new object tree. It never trusts it blindly — it : diffs new against old and emits a small set of DOM ops to close the gap.
Pick a state change, then scrub the four lenses (or tab them). The Reconciler tab is the actual ~40-line source running here. Note how a big-looking change often costs one op.
key marks stable children — to diff in O(n). So the patches you
see here are small and good enough, not mathematically smallest. The toy reconciler below makes the same trade.Why is that “provably minimal” diff too slow to use — and what two assumptions let React do it in O(n)? Walk the three stages ↗
key?You won't get far rendering a list before React scolds you: "Each child in a list should have a unique key
prop." Annoying. So you type key={index}, the warning disappears, and you move on. Then one day the
list reorders — an input shows the wrong value, a checkbox checks the wrong row.
Let's make that happen on purpose. It's the fastest way to see what a actually buys you.
Do this: type into the first input of both lists (e.g. "MY TEXT"), then hit Prepend. Watch where your text lands.
Why does it default to position? It comes down to one question React asks about every child on every render — and it's worth watching it decide.
A key is a
promise of identity across renders.
key={index}
isn't wrong — it's genuinely fine when a list never reorders, filters, or splices. There's no identity to confuse. It only
bites the moment positions and items stop lining up.
That promise has a price inside the engine. While children only match by position, the reconciler can always compute where a node lives — “the child at index i.” The moment a key lets a node move, that arithmetic dies: React must instead carry a pointer to each element's real node, wherever it ends up. That shift — identity as a stored pointer, not a computed position — is the first plank of the fiber tree you'll meet in movement 10.
key={index} and hope.Everything so far connects here. hands you a value and a way to say "I changed it, run me again." Running again makes a new object tree → reconciled → patched. One loop, forever.
Step back and look at the shape of the whole thing. In movement 01, you were the
runtime: you held the state→DOM mapping in your head and re-ran it by hand at every edge. Every movement since has
been quietly lifting that job out of you and handing it to code. useState holds the state you used to
track yourself. The render function is the mapping you used to run in your head — now written down exactly once.
Reconciliation is the by-hand patching from 01, generalized into forty-odd lines. The loop below is simply that machinery,
turning. You didn't surrender control; you named the thing you were already doing, and let the computer do
it faster and without forgetting.
React.memo and bailouts can prune even that. Multiple setState
calls are batched into one pass rather than one-per-call. And updates are scheduled, not synchronous — the
"new value" isn't readable on the very next line. The five-step loop is the true shape; production just adds when
and how much on top.That marginalia just made three claims on faith — batched, scheduled, “not readable on the next line.” The cell sketch below can't produce any of them: it writes the cell the instant you call the setter. What has to change? Walk the three stages ↗
So what is useState, mechanically? A value in a cell plus a scheduler callback. Three views:
Fig.A hook isn’t magic — it’s an index into a per-component array. Each useState claims the next cell by call order; setState writes to that cell and re-runs your function, which reads the same cells again. Keep the call order identical and the cells always line up. Tap Step to watch one full turn.
Fig.Hooks bind to state by call order, not by name — the Nth useState always reads the Nth cell. Put one inside an if and a render where it’s skipped renumbers every hook after it, so they read the wrong cells. That’s the whole reason for the rule: same hooks, same order, every render. Tap Step.
cells array would collapse the
moment you mounted the component twice. React keeps a separate hook list per component instance, stored
on that instance's internal node (its fiber), and swaps the right one in before each render. The mechanism you care
about is unchanged — fixed slots, matched by call order — but it's per-instance, not one global array.Fig.State is a per-render snapshot: inside a single render, count is a constant. Calling setCount(count + 1) three times all reads the same value, so you get +1 — while setCount(c => c + 1) threads each result into the next, giving +3. Tap Step.
A re-render is just "call the function again." You didn't surrender control to React — you named the state→DOM mapping you were already running by hand in movement 01, and let the machine turn the loop faster and without forgetting.
Every piece is now on the table: elements as objects, a reconcile, a render loop, per-instance hooks. Here they are
assembled into a real runtime — and the source on the left is literally the code running the app on the right
(printed straight from the live functions via .toString()).
createElement is movement 03–04. reconcile is
movement 05. useState is movement 07 — now with the per-instance hook record from that section's caveat.
Nothing here is pseudocode; it's the whole runtime, and it fits on one screen.Your component function runs in the — pure, no DOM touched, safe to discard or re-run. Only once React is sure does the apply the patch. run after that, once the browser has painted.
useEffect.Feel the dependency array decide. The theme below is an effect dependency; the counter is not. Watch which changes fire the effect.
The timeline above says effects run after paint — yet the mini-react driving that very demo runs them at commit, and nothing looked wrong. So when does the difference bite — and why does React ship two effect hooks? Walk the three stages ↗
Render is a draft; commit is the ink. Because the draft is pure and discardable, React can build it, throw it away, and build it again — at different priorities, or paused mid-way — and the real DOM only ever sees the finished, all-at-once patch.
Our reconcile recurses the whole tree in one synchronous call. For a big tree that's a long task that blocks the main thread — no input, no paint, until it finishes. rewrites the walk as a that can stop and resume.
function reconcile(parent, old, next) {
// ...patch this node...
for (let i = 0; i < n; i++)
reconcile(node, old[i], next[i]); // ← can't pause
}
// the call stack IS the position.
// you cannot return to the browser
// halfway down without losing it.let next = root; // a pointer
function workLoop(deadline) {
while (next && deadline.timeRemaining() > 1)
next = performUnit(next); // do ONE node
if (next) requestIdleCallback(workLoop); // resume later
else commit(root); // all done → commit once
}
// each fiber links to child / sibling / return,
// so the walk can stop after any single unit.child, sibling,
return (parent) — so the traversal state lives in the data, not the call stack. The loop can hand
control back to the browser after any unit and pick up exactly where it left off.Pausing is only half the trick. When the walk stops midway — or gets thrown away for something more urgent — where has the half-finished tree been living? It can't be the one the screen believes in. Walk the three stages ↗
Make position data, not
stack. Once the walk's place lives in child/sibling/return pointers instead of
the call stack, the diff can stop after any single node and resume later — which is the one thing recursion could never do.
The marginalia above tossed off a claim — that all this “unlocks Suspense.” Cash it: a component deep in the tree discovers, mid-render, that its data hasn't arrived. What can it possibly do from down there? Walk the three stages ↗
Everything so far lived in one file. The real React is a monorepo that cuts the same machine along its seams: one package describes UI, one turns descriptions into a platform, and the engine in between is shared by every renderer. Here's the map, and what each function actually does.
You install two packages — react and react-dom — and it's tempting to think of them as one thing. They are deliberately not. react is the description layer: it knows how to make element objects and expose hooks, and it knows nothing about the DOM. react-dom is the renderer: it turns those descriptions into real browser nodes. Between them sits react-reconciler — the Fiber tree, the diff, and the actual hook implementations you reverse-engineered — plus scheduler, which decides when the work runs. The split is what lets the same react drive the browser, native, canvas, or a test renderer without changing a line.
react answers “what is the UI?” — import only it and nothing ever appears on screen. react-dom answers “put it in this browser.” Rendering doesn't begin until a renderer's entry point runs: createRoot(node).render(<App/>). React Native imports the same react and a different renderer — that is the entire reason the API is split.
So what's actually in each package? Below is the working set — the functions you call, and the ones underneath that do the work.
{ $$typeof, type, key, ref, props }. Every JSX tag compiles to this call. It only describes a node; it creates no DOM.react/jsx-runtime (the “automatic runtime”). Same job as createElement, but children arrive inside props and the dev build records source location.Children.map/forEach/count/toArray/only safely walk the opaque props.children.this.props/this.state and a setState/forceUpdate that the reconciler wires up. PureComponent adds a shallow-compare shouldComponentUpdate.Provider and Consumer. The reconciler reads it during render so useContext can find the nearest provider's value.$$typeof: memo skips a re-render when props are shallow-equal, forwardRef passes a ref through, lazy suspends until a dynamic import resolves.react package each hook just forwards to the current dispatcher — the real logic lives in the reconciler (next block). That indirection is what keeps react host-agnostic.use unwraps a promise or context during render; the others support form actions and optimistic UI. Same facade pattern — they resolve to the dispatcher.startTransition marks an update as low-priority. A guarded internals object (historically __SECRET_INTERNALS…) holds the shared dispatcher slot the hook facades read from.// in the `react` package — the public hook is a thin facade export function useState(initialState) { const dispatcher = resolveDispatcher(); // reads the *current* dispatcher return dispatcher.useState(initialState); // ← the real work is in react-reconciler } // react-reconciler swaps the dispatcher before each render: // first render → HooksDispatcherOnMount (mountState, mountEffect…) // re-render → HooksDispatcherOnUpdate (updateState, updateEffect…) // Outside render no dispatcher is set — which is why calling a // hook there throws, and why hook order must stay identical (movement 07).
useState you call resolves to mountState on the first render and updateState after — the react package never contains either.react-dom/client. Creates a root tied to a DOM node and returns { render, unmount }. This is where rendering starts and where concurrent features are enabled — the React 18+ replacement for the old ReactDOM.render.createRoot, but adopts server-rendered HTML already in the container — attaching event handlers and state instead of recreating nodes.fn to render and commit synchronously before returning — an escape hatch out of batching when you must read the DOM immediately after a state change.react-dom/server. Turn a tree into HTML on the server — the streaming variants flush markup progressively and integrate with Suspense boundaries.type/props, its hook list (memoizedState), the child/sibling/return links you used in movement 10, and effect flags + lanes (priority).setState marks a fiber dirty with a lane and asks the scheduler to do work — the start of every re-render.performUnitOfWork. The concurrent version checks shouldYield() between units so it can pause; the sync version runs straight through. This is exactly your movement-10 work loop.renderWithHooks) to get its children, then reconcileChildren diffs them and produces child fibers.key, reusing fibers where it can and flagging the rest for placement, update, or deletion.useLayoutEffect and attaches refs. This is the “commit” half of movement 09.useEffect callbacks (and their cleanups) after paint, asynchronously — the “effects run after the screen updates” rule, enforced here.renderWithHooks points the dispatcher at the mount or update table and calls your component; mountState/updateState are the actual useState — the slot-by-call-order machinery from movement 07, per fiber.true once the current task has used its slice of the frame, telling the work loop to pause and resume later — the production version of your deadline.timeRemaining() > 1 check.MessageChannel (not requestIdleCallback) for predictable, fast yielding. This package is what makes Fiber's interruptibility real.isElement, isFragment, …). It is the most-downloaded React package because so many libraries depend on it.react-native-renderer, react-art, react-test-renderer, react-noop-renderer.setState calls the reconciler's scheduleUpdateOnFiber, tagging the update with a lane → scheduler picks it up and runs the work loop, which calls your component through the dispatcher that react exposes → commitRoot applies the result through react-dom's host config → the browser paints → useEffect flushes after. Four packages, one render — and react itself never touched a DOM node.The seam that organizes the
whole codebase is "describe" vs "paint." react describes, a renderer paints, and the engine you built —
diff and work loop — is shared between them. Swap the renderer and the same description runs somewhere new.