The Big Picture (start here)
The one journey every state change takes β and a plain-language glossary of the jargon
Everything in this guide is really about one pipeline: you change some state, and React turns that into the smallest possible update to the screen. Here's the whole journey on one line β keep this picture in mind as you read.
π Glossary β the jargon in plain language
Quick, layman one-liners. Each is unpacked properly in the sections below.
You write step-by-step instructions for how to change the screen β like turn-by-turn driving directions.
You describe what the screen should look like; React works out the steps β like naming a destination and letting GPS drive.
Data a component remembers between renders. Change it β the UI re-renders.
Inputs passed into a component from its parent.
Runs start-to-finish right now, blocking everything else until done.
Starts now but finishes later β the result comes back after a wait (e.g. a network call). Frees the thread meanwhile.
Not background work β just postponed a moment, then run synchronously later in the same cycle. (This is what setState does.)
The browser recalculating the size & position of elements. Expensive.
The browser re-drawing pixels (color, text) without recomputing positions. Cheaper than reflow.
A lightweight JavaScript copy of the UI that React diffs against β the user never touches it.
React's process of comparing the new UI description with the old one to decide what changed.
The cheap O(n) algorithm inside reconciliation that actually spots the differences.
React's engine that runs reconciliation β it can pause, resume and prioritize work so the app stays responsive.
React re-runs your components to build the new UI description. Pure JS β no DOM changes yet.
React applies the computed changes to the real DOM. The only step that touches the screen.
Grouping several state updates into a single re-render instead of one render each.
Functions like useState/useEffect that let function components remember state & run side effects.
An old snapshot of a variable captured by a function β why state can look "one step behind."
React preparing several UI updates at once, pausing less-urgent ones so urgent ones stay snappy.
A plain JS object describing a piece of UI (e.g. a button) β a description, not a real DOM node.
1. What Problem Did React Actually Solve?
The declarative model, and why everything downstream is a consequence of it
The world before React: imperative DOM
In vanilla JS you don't describe what the UI should look like β you describe the steps to mutate it. You are personally responsible for the transition between every possible state and every other state.
function addMessage(msg) {
messages.push(msg);
// YOU manually keep the DOM in sync:
countEl.textContent = messages.length;
const li = document.createElement('li');
li.textContent = msg.text;
if (msg.unread) li.classList.add('unread');
listEl.appendChild(li);
}
This produces three compounding pains:
- State lives in two places β your JS variables and the DOM β and they drift out of sync. Most "weird UI bugs" are really sync bugs.
- Complexity is O(transitions), not O(states). With N pieces of state, the ways to move between them explode. jQuery apps collapsed under this.
- It's not composable. A widget's correctness depends on every call site updating it correctly.
React's core insight
Describe the UI as a pure function of state. Re-run that function on every change. Let the library figure out the DOM mutations.
UI = f(state)
You write the destination ("given this state, the UI looks like this"), never the path. This collapses the O(transitions) problem back to O(states) β you only ever reason about "what does the UI look like in this state."
function Messages({ messages }) {
return (
<>
<span>{messages.length}</span>
<ul>
{messages.map(m => (
<li className={m.unread ? 'unread' : ''}>{m.text}</li>
))}
</ul>
</>
);
}
The model / execution split β this IS React
The obvious objection: "Re-run everything and rebuild the DOM on every keystroke? The DOM is slow!" Correct. So React splits the idea into two layers:
"Pretend you rebuild everything from scratch." Cheap to reason about.
Build a lightweight JS description (Virtual DOM), diff it against the previous one, apply only the minimal real-DOM mutations.
The VDOM was never primarily about being faster than hand-written DOM code. React's win is making maintainable, correct UI fast enough β trading peak performance for an enormous gain in how you reason about the program.
2. Why Touching the DOM Is Expensive (Reflow & Repaint)
The cost model that motivates batching, the VDOM, and concurrent rendering
"The DOM is slow" isn't about the data structure β reading/writing a JS property is fast. What's expensive is that certain DOM operations force the browser to redo parts of its rendering pipeline synchronously.
The browser rendering pipeline
- JS β your code runs, possibly mutating the DOM/CSSOM.
- Style (recalc) β which CSS rules apply to which elements.
- Layout (reflow) β compute geometry (x/y/width/height) of every affected box. The expensive one.
- Paint β fill in pixels (text, colors, borders) into layers.
- Composite β GPU assembles painted layers into the final image.
The stages are dependent and mostly sequential β the earlier the stage you dirty, the more downstream work you trigger:
| You change⦠| Triggers |
|---|---|
| transform / opacity | Composite only (cheapest β GPU) |
| color / background | Paint + Composite |
| width / height / font | Layout + Paint + Composite (most expensive) |
| add / remove DOM node | Style + Layout + Paint + Composite |
This is exactly why "animate transform/opacity, not top/width" exists β those skip Layout and Paint and run on the compositor.
Forced (synchronous) reflow vs deferred reflow β the silent killer
Deferred / coalesced reflow: the browser batches your DOM writes into a dirty queue and reflows once, lazily, at the end of the current JS task.
Forced (synchronous) reflow: reading a layout-dependent property (offsetTop, offsetWidth, scrollHeight, getBoundingClientRect(), getComputedStyle()) forces the browser to flush the queue and reflow right now to answer truthfully.
Interleaving reads and writes turns one reflow into N β layout thrashing:
for (const el of boxes) {
const w = el.offsetWidth; // READ β forces reflow
el.style.width = (w*2) + 'px';// WRITE β dirties layout
}
// read forces reflow, write dirties,
// next read forces reflow again...
const widths = boxes.map(el => el.offsetWidth); // all reads
boxes.forEach((el, i) =>
el.style.width = widths[i]*2 + 'px'); // all writes
// reads never interleave with writes β
// browser coalesces into ONE reflow
A loop of pure writes does not reflow per iteration. The writes just queue dirty flags; the single reflow happens after the loop (deferred). Reflows multiply only when a layout-read is interleaved between writes, forcing a flush each time.
How React attacks this
- Diff in JS, write to the DOM in one coordinated phase. Reconciliation runs against the VDOM (no layout cost); the commit phase applies all mutations together. React owns the write phase, so reads/writes don't interleave randomly across your codebase.
- Batching state updates. Multiple
setStatecalls collapse into one re-render + one commit + one reflow. (React 18 made this automatic everywhere β that release note is really a reflow-minimization story.) - Minimal mutations. The diff means React touches only changed nodes, keeping the reflow scope small.
The VDOM is not faster than the DOM β it's a staging area where React computes & coordinates DOM writes so the expensive pipeline runs as few times, over as small a region, as possible. It systematizes the hand-discipline (batch reads, batch writes, mutate minimally) that vanilla JS leaves entirely up to you.
React does not save you from forced sync layout if you trigger it (e.g. reading ref.current.offsetHeight in useLayoutEffect then setting state that mutates the same element). The architecture protects against the accidental, distributed version of the problem; deliberate layout reads are still your responsibility.
3. The Mental Model + setState & Batching
Elements as descriptions, the renderβcommit flow, and whether setState is "async"
A component is a function that produces a description
JSX is not magic. <button className="primary">Save</button> compiles to a function call returning a plain JS object β a React element:
{
type: 'button', // string for host, function for a component
props: { className: 'primary', children: 'Save' },
key: null,
}
An element is inert data β no DOM node, no instance. This is why "re-render" is cheap to describe: re-running a component just produces a new tree of these cheap objects.
The full flow: setState β screen
- Render β DOM update. Steps 2β3 are pure JS, no reflow. Only commit touches the DOM. "Re-rendered 40 times" with identical output commits nothing.
- Rendering is recursive, top-down. A re-render re-runs children too; reconciliation then usually finds "same as before."
memois an escape hatch to skip that. - setState is a request, not a command. It schedules. This gap is where React 18's concurrency lives.
The two trees (double buffering)
alternate. Current stays valid until commit flips the pointer β that's why an in-progress render can be thrown away.Reconciliation diffs WIP against current; commit swaps WIP to become current. The current tree is untouched until commit β which is why concurrent mode can throw away an in-progress render without corrupting the screen.
Is setState async? The classic puzzle
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
console.log(count); // prints 0 β and the screen shows 1, not 3
}
| Symptom | Real cause | Fix |
|---|---|---|
| "count didn't update on the next line" (logs 0) | Stale closure β each render freezes its own count as a const | Don't rely on it; read in next render / use updater |
| "only updated once / one render despite 3 calls" (shows 1) | Batching β 3Γ setCount(0+1) collapse into one render | Functional updater setX(prev => ...) |
The fix when next state depends on previous β pass a function, not a value:
setCount(prev => prev + 1); // 0 β 1
setCount(prev => prev + 1); // 1 β 2
setCount(prev => prev + 1); // 2 β 3 (still ONE render)
setState is not async (no promise, no event-loop task). It's deferred + batched β processed synchronously later in the same tick. You can't read the new value on the next line not because "it hasn't finished" but because the scope closed over the old value.
Batch boundaries: React 17 β 18
Batched only inside React event handlers. Inside setTimeout/Promise.then/native handlers, each setState = its own render+commit+reflow.
Batches everywhere (timeouts, promises, native handlers), via the scheduler (Fiber). Same infra as concurrent rendering.
Opting out: flushSync(() => setX(...)) forces a synchronous render+commit when you must read layout between updates. Rare, and it costs extra reflows.
4. Reconciliation & the Diffing Algorithm
How "here's the whole new tree" becomes "change these 3 nodes"
Comparing two arbitrary trees for the minimal edit set is O(nΒ³) β unusable. React doesn't solve the general problem; it cheats with two heuristics that drop it to O(n), accepting occasionally-non-minimal DOM work. Those two heuristics are reconciliation.
Heuristic 1: different type β throw the subtree away
At each position, React asks first: is type the same?
- Same host type (
'div'): keep the DOM node, patch changed attributes, recurse into children. - Same composite type (a component): keep the instance β preserve its state and hooks β feed new props, re-render.
- Different type (
'div'β'span',AβB): tear down the entire old subtree (unmount, run cleanups, destroy DOM) and build the new one from scratch.
A type change destroys all state and DOM below it β even an identical child, because its parent's type changed and the whole branch was rebuilt.
Heuristic 2: key gives identity within a list
Heuristic 1 compares by position. For lists that reorder/insert/delete, position-matching is catastrophic. key lets React match by identity, not slot.
{items.map((it, i) =>
<li key={i}>...</li>)}
// prepend Z: key 0 was A, now "is" Z
// β React patches AβZ, BβA, adds C
// β an input's text sticks to the
// SLOT, not the data. Wrong row!
{items.map(it =>
<li key={it.id}>...</li>)}
// prepend Z: A and B keys unchanged
// β React inserts ONE node at front,
// leaves A & B (and their state) alone.
Since a changed key = "different identity = unmount + remount", you can force a state reset: <Profile key={userId} /> β when userId changes, a fresh Profile mounts with reset state. Cleaner than a manual clearing effect.
The precise "same element" rule
old & new match as the SAME instance βΊ
same position in parent AND same `type` AND same `key`
This one rule explains: why moving a component between parents remounts it; why cond ? <A/> : <B/> with different types loses state; why two sibling same-type elements in the same slot surprisingly preserve state.
key and "type at a position" are not list trivia β they are React's definition of component identity, and identity decides whether state survives a re-render. Most "state mysteriously reset / stuck to the wrong row" bugs are identity bugs, not state bugs. All of this runs in the render phase (pure, no DOM); commit applies the result.
5. React Fiber
The keystone: turning an un-pausable recursion into a pausable, prioritizable loop
The problem: the stack reconciler couldn't pause
Pre-16 React used the stack reconciler β plain recursion that walked the tree depth-first. The fatal property: recursion uses the JS call stack, and you cannot pause a call stack. JS is single-threaded with run-to-completion semantics β once a deep recursion starts, it runs to the end before the browser can paint or handle input. A large update = a 50β200ms freeze.
React wanted to pause mid-render, resume, prioritize (urgent keystroke before expensive list), and abort stale work. None of that is possible with the call stack as the work queue.
The fix: reify the stack as a linked list on the heap
A fiber is a stack frame turned into a plain JS object that React owns. A call stack is the engine's private, forward-only, unstoppable structure. React rebuilt it as an explicit object graph it holds a pointer into β so it can save, resume, restart, and reprioritize the work.
fiber = {
type, key, // same identity fields as the element (Heuristic 1 & 2)
stateNode, // the real DOM node, or class instance
memoizedState, // hooks list (useState/useEffect chain) / class state
memoizedProps, pendingProps,
// the tree, as POINTERS (a linked list, not a children array):
child, // first child
sibling, // next sibling
return, // parent ("return address", like a stack frame)
alternate, // link to this fiber's counterpart in the OTHER tree
flags, // effect tags: Placement / Update / Deletion
lanes, // priority of pending work
}
child/sibling/returnreplace recursion: a depth-first walk done with awhileloop over pointers β and a loop, unlike recursion, can stop after any step and resume later.alternateimplements the two trees (double buffering). Building WIP never mutates current, so an aborted render just drops WIP β screen stays valid. Commit swapsroot.current = workInProgressatomically.
Two phases, very different rules
React may run the render phase multiple times or throw it away. That's the real reason StrictMode double-invokes renders in dev (to surface side effects), and why "don't mutate state/refs/DOM during render" is a hard rule, not etiquette. Commit, by contrast, is not interruptible β a half-applied DOM is a corrupt screen.
The work loop & time slicing
function workLoop(deadline) {
while (nextUnitOfWork && !shouldYield()) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork); // do ONE fiber, return next
}
if (nextUnitOfWork) scheduleResume(workLoop); // more to do β yield, resume later
else commitRoot(); // tree done β commit
}
After each fiber, React checks shouldYield() (~5ms slice used up? more urgent work?). If yes, it breaks the loop and hands the thread back to the browser, then resumes from nextUnitOfWork later. That's time slicing.
Returning the next fiber just advances the loop. The yield is a separate decision: break the loop, keep the cursor in a module-level variable (nextUnitOfWork), re-schedule, re-enter and read that variable. Resume is "save a bookmark + come back," not a language feature.
React evaluated generators (which can pause via yield) and rejected them: (1) colored/infectious β every function in the chain must be function*; (2) forward-only β you can't abort, restart-from-root, or reprioritize a generator's opaque state; (3) memory/perf overhead. A hand-rolled linked-list + loop gives all of pause/resume/abort/restart/prioritize.
The actual resume mechanism is React's Scheduler, built on MessageChannel (not setTimeout): postMessage schedules a macrotask that runs after the browser paints/handles input, without setTimeout's ~4ms clamp.
A minimal JS model of the work loop
JS has no built-in linked list, but an object holding references to other objects is one. Here's Fiber's traversal in ~30 lines:
// A "fiber" is just an object with pointers to other fibers
function fiber(name, children = []) {
return { name, child: null, sibling: null, return: null, children };
}
function link(node) { // build child/sibling/return links
let prev = null;
for (const c of node.children) {
c.return = node;
if (!prev) node.child = c; else prev.sibling = c;
prev = c; link(c);
}
return node;
}
const root = link(fiber('App', [
fiber('Header', [fiber('Logo'), fiber('Nav')]),
fiber('Main', [fiber('List')]),
]));
let nextUnitOfWork = root; // the BOOKMARK β a module-level variable
function performUnitOfWork(f) { // do ONE fiber, return the NEXT (DFS)
console.log('work:', f.name);
if (f.child) return f.child; // go down
let node = f;
while (node) {
if (node.sibling) return node.sibling; // go right
node = node.return; // go up, then try sibling
}
return null;
}
function workLoop() {
let budget = 2; // pretend "5ms" = 2 nodes per slice
while (nextUnitOfWork && budget-- > 0) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
if (nextUnitOfWork) {
console.log(' βΈ yield (bookmark =', nextUnitOfWork.name, ')');
setTimeout(workLoop, 0); // stand-in for MessageChannel
} else console.log(' β
done β commit');
}
workLoop();
| Toy | Real React |
|---|---|
child/sibling/return | the fiber linked list (reified call stack) |
nextUnitOfWork module var | the saved cursor / bookmark |
budget-- | shouldYield() (~5ms time slice) |
setTimeout(workLoop, 0) | MessageChannel.postMessage (resume after paint) |
Priority: lanes & concurrency
React 18 tags each update with priority as bitmask lanes. Urgent updates (clicks, typing) get high-priority lanes; startTransition/useDeferredValue get low-priority transition lanes. The scheduler renders urgent lanes first and can interrupt & restart an in-progress low-priority render.
typing in a box that filters a 10k list:
keystroke β urgent lane β renders the input immediately
list filter β transition lane β rendered in slices, interruptible,
restarted if you keep typing β no jank
Fiber turns "render the tree" from an un-pausable recursive call into a pausable, prioritizable, restartable loop over a heap data structure β and that single change is the foundation of every modern React feature. (Note: "concurrent" = the capability β preparing/prioritizing multiple renders β that pausability unlocks. Still single-threaded; it slices and reprioritizes work on the main thread.)
6. Hooks Internals: State & Rendering
Hooks as a linked list on the fiber β and why every hook rule follows from it
How does useState know which state is "yours"?
Two identical useState('') calls β no key, no name. React matches state to call by order, storing hooks in a linked list on the fiber, walked in call order each render.
function useState(initial) {
const fiber = currentFiber;
const hook = fiber.hooks[hookIndex] ?? { state: initial, queue: [] };
// drain queued updates from setState (the batch!)
hook.queue.forEach(u => hook.state = typeof u === 'function' ? u(hook.state) : u);
hook.queue = [];
fiber.hooks[hookIndex] = hook;
const i = hookIndex;
const setState = (u) => { fiber.hooks[i].queue.push(u); scheduleRender(fiber); };
hookIndex++; // advance for the NEXT hook call
return [hook.state, setState];
}
React matches hooks to state purely by index. A hook behind an if shifts every subsequent index when the condition flips β count would read what name had. Hooks must be top-level, unconditional, same order every render. The rule is the data structure's only invariant.
Also note: setState closes over fiber + index, not the value β so it always reaches the latest state, and its identity is stable across renders (safe in dep arrays). useState is internally useReducer with a built-in reducer.
useRef = the deliberate escape
value + setter; mutate via setter β triggers render. For things the UI is a function of.
a mutable box {current} on the fiber; mutate directly β NO render; same object every render. For things to persist that the UI is not a function of (timer IDs, previous value, DOM nodes).
Effect timing: useLayoutEffect vs useEffect
- useEffect (default, 99%): after paint, non-blocking. Data fetching, subscriptions, logging.
- useLayoutEffect: before paint, sync. Only when you must read layout and re-mutate before the user sees it (measure β reposition a tooltip, sync scroll) to avoid a visible flicker. Costs: blocks paint, can force sync reflow (Part 2).
The dependency array decides whether an effect re-runs (shallow Object.is compare): [] = mount/unmount only; no array = every render. Cleanup runs before the next run and on unmount.
Re-render rules & memoization
A re-render = re-run your function + diff (cheap-ish). It is not a DOM update. Cost becomes real when the function does expensive work, the subtree is large, or a child genuinely commits DOM changes.
| Tool | Memoizes | Use when |
|---|---|---|
React.memo(Cmp) | the rendered output (skips re-run if props shallow-equal) | a child re-renders often with the same props and is expensive |
useMemo(fn, deps) | a computed value / a stable object reference | expensive calc, or you need a stable ref to feed a memo'd child |
useCallback(fn, deps) | a function reference (= useMemo(() => fn, deps)) | passing a callback to a memo'd child |
React.memo only helps if props are stable. Pass a fresh {} / [] / arrow as a prop and memo always sees "changed." That's why useCallback/useMemo exist β to keep references stable so memo can do its job. They're a system, not three separate speedups.
Memoization isn't free (memory + a deps compare every render). Sprinkled everywhere it often costs more than it saves. Use the React DevTools Profiler (flamegraph: which components rendered, why, how long) or the <Profiler onRender> component to confirm a real expensive re-render before optimizing. React 19's Compiler auto-inserts this memoization at build time β knowing what it automates is the point.
7. The React 16 / 18 / 19 Timeline
Every modern feature is a consequence of one 2017 decision (Fiber)
React 16 (2017) β "rebuild the foundation"
The Fiber rewrite. Headline features are all things the stack reconciler couldn't do:
| Feature | Why Fiber enabled it |
|---|---|
| Error Boundaries | Fiber can unwind a subtree and render a fallback |
Fragments <></> | a fiber's child/sibling list allows multiple roots β no wrapper div |
| Portals | decouples tree position (logical parent) from DOM position |
| Hooks (16.8, 2019) | hook state lives on the fiber as a linked list β no fiber, nowhere to store it |
React 18 (2022) β "turn on concurrency"
The machinery sat dormant since 16; createRoot switched it on. Each feature is a direct use of pausable + prioritizable rendering:
- useTransition / startTransition β mark an update low-priority; rendered in interruptible slices, preempted by urgent updates. (The lanes model exposed as API.)
- useDeferredValue β same idea, value-side.
- Automatic batching β now batches everywhere (Part 3), via the scheduler.
- Suspense for data + Streaming SSR β a component "suspends" (throws a promise); Fiber pauses that subtree, shows a fallback, resumes on resolve.
renderToPipeableStreamstreams HTML in chunks with selective hydration. - useId β hydration-safe stable IDs.
- useSyncExternalStore β lets external stores (Redux/Zustand) subscribe without tearing under concurrent rendering (a risk concurrency itself introduced).
React 19 (2024) β "product features on top of concurrency"
- Actions / useActionState /
<form action>β async functions wired into transitions; pending state, errors, sequencing handled for you. (Under the hood: transitions.) - use(promise) / use(context) β read a promise or context during render, integrates with Suspense; can be called conditionally (it's a primitive, not an index-slot hook).
- useOptimistic β built-in optimistic UI with auto-revert on failure.
- React Server Components + Server Actions β components that render server-only (zero client JS), direct backend access;
'use server'RPC-style calls. Changes whereUI = f(state)runs. - React Compiler ("Forget") β build-time auto-memoization, making manual
memo/useMemo/useCallbacklargely unnecessary. - Smaller wins β
refas a regular prop (noforwardRef),<Context>as provider, document metadata hoisting, resource preloading.
The whole arc on one page
Every modern React feature is a consequence of one 2017 decision β reify the call stack as an interruptible linked list (Fiber). 16 built it, 18 turned on its concurrency, 19 ships the product-level patterns concurrency makes possible.
Summary & Mental Model
The whole guide compressed β one picture, one sentence per concept
You write UI = f(state) β describe what the screen should be, never how to change it. When state changes, React re-runs your function to build a cheap JS description (Virtual DOM), diffs it against the previous one (reconciliation) to find the minimum that changed, and applies only that to the real DOM in one coordinated commit. The engine doing all this β Fiber β can pause, prioritize and resume the work so the browser stays smooth. Everything else (batching, keys, hooks, concurrency) is detail hanging off this one loop.
One line per concept
| Concept | In one sentence |
|---|---|
| Declarative model | You describe the destination (UI = f(state)), not the path β so you reason about states, not transitions. |
| Virtual DOM | A cheap JS copy of the UI React diffs against; it's a staging area, not a speed trick. |
| Reconciliation | The process of comparing new vs old UI to decide the minimal DOM changes. |
| Diffing | The O(n) heuristics inside reconciliation: same type or rebuild, and key for list identity. |
| React Fiber | The call stack reified as an interruptible linked list β so rendering can pause, resume, prioritize and abort. |
| Render phase | Re-run components, build the tree, compute the diff. Pure, interruptible, no DOM. |
| Commit phase | Apply the changes to the real DOM in one atomic pass β the only step that reflows. |
| Reflow | Browser recomputing geometry; React minimizes it via batching + minimal, coordinated commits. |
| Batching | Many setState calls collapse into one render + one commit + one reflow. |
| "setState is async?" | No β it's deferred + batched; you see the old value because the closure froze it, not because it's pending. |
| Hooks | Ordered slots on the fiber, matched by call order β which is the whole reason for the Rules of Hooks. |
| Concurrency | Fiber's pausability used to prepare/prioritize multiple updates so urgent ones (typing) never block. |
Every modern React feature is a consequence of one 2017 decision β reify the call stack as an interruptible linked list (Fiber). React 16 built it, 18 turned on its concurrency, 19 ships the product patterns concurrency makes possible.