🧠 React Internals β€” Behind the Scenes

From the declarative model to Fiber, hooks & the React 16/18/19 timeline β€” the "why" underneath the API.

πŸ—ΊοΈ

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.

Virtual DOM β€” JS copy, no real DOM State change setState(...) Render build new Virtual DOM Reconcile diff vs old (Diffing) Commit update REAL DOM 🎨 Paint user sees it ✏️ real DOM βš™ React Fiber β€” the engine that runs all this Render & Reconcile are pausable / prioritizable; Commit is one fast, atomic step
State β†’ (Fiber: build Virtual DOM β†’ diff β†’ commit) β†’ screen. Only the last step touches the real DOM.

πŸ”‘ Glossary β€” the jargon in plain language

Quick, layman one-liners. Each is unpacked properly in the sections below.

Imperative

You write step-by-step instructions for how to change the screen β€” like turn-by-turn driving directions.

Declarative

You describe what the screen should look like; React works out the steps β€” like naming a destination and letting GPS drive.

State

Data a component remembers between renders. Change it β†’ the UI re-renders.

Props

Inputs passed into a component from its parent.

Synchronous

Runs start-to-finish right now, blocking everything else until done.

Asynchronous (async)

Starts now but finishes later β€” the result comes back after a wait (e.g. a network call). Frees the thread meanwhile.

Deferred

Not background work β€” just postponed a moment, then run synchronously later in the same cycle. (This is what setState does.)

Reflow (layout)

The browser recalculating the size & position of elements. Expensive.

Repaint

The browser re-drawing pixels (color, text) without recomputing positions. Cheaper than reflow.

Virtual DOM

A lightweight JavaScript copy of the UI that React diffs against β€” the user never touches it.

Reconciliation

React's process of comparing the new UI description with the old one to decide what changed.

Diffing

The cheap O(n) algorithm inside reconciliation that actually spots the differences.

React Fiber

React's engine that runs reconciliation β€” it can pause, resume and prioritize work so the app stays responsive.

Render (phase)

React re-runs your components to build the new UI description. Pure JS β€” no DOM changes yet.

Commit (phase)

React applies the computed changes to the real DOM. The only step that touches the screen.

Batching

Grouping several state updates into a single re-render instead of one render each.

Hooks

Functions like useState/useEffect that let function components remember state & run side effects.

Stale closure

An old snapshot of a variable captured by a function β€” why state can look "one step behind."

Concurrency

React preparing several UI updates at once, pausing less-urgent ones so urgent ones stay snappy.

Element

A plain JS object describing a piece of UI (e.g. a button) β€” a description, not a real DOM node.

🎯

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:

❌ Imperative β€” two sources of truth JS state DOM you sync by hand ⚠ drift out of sync = bugs complexity = O(transitions) βœ… Declarative β€” UI = f(state) state f() UI React syncs the DOM for you complexity = O(states)
You describe the destination, not the path between states. One source of truth.

React's core insight

πŸ’‘ The one idea

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:

Programming model (what you see)

"Pretend you rebuild everything from scratch." Cheap to reason about.

Execution (underneath)

Build a lightweight JS description (Virtual DOM), diff it against the previous one, apply only the minimal real-DOM mutations.

βœ… Key takeaway

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.

βš™οΈ

"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 mutate DOM/CSSOM Style recalc styles Layout reflow β€” geometry Paint fill pixels Composite GPU β€” cheap Dirty an earlier stage β†’ more downstream work runs width / height / font / add-remove node = the expensive path transform / opacity = skip to here
Stages are sequential & dependent β€” the earlier you invalidate, the more work cascades.
  1. JS β€” your code runs, possibly mutating the DOM/CSSOM.
  2. Style (recalc) β€” which CSS rules apply to which elements.
  3. Layout (reflow) β€” compute geometry (x/y/width/height) of every affected box. The expensive one.
  4. Paint β€” fill in pixels (text, colors, borders) into layers.
  5. 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 / opacityComposite only (cheapest β€” GPU)
color / backgroundPaint + Composite
width / height / fontLayout + Paint + Composite (most expensive)
add / remove DOM nodeStyle + 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

πŸ“Œ The distinction to remember

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:

❌ Thrashing β€” N reflows
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...
βœ… Batched β€” 1 reflow
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
⚠️ Writes alone still reflow β€” but only once

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.

R = layout read (offsetWidth…) W = DOM write ⚑ = forced reflow βœ… Batched: all reads, then all writes R R R W W W 1 reflow βœ“ ❌ Thrashing: read/write interleaved R W R W R W ⚑ ⚑ ⚑ N reflows βœ—
Each read interleaved between writes forces the queue to flush β†’ one reflow becomes N.

How React attacks this

βœ… Precise statement

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.

⚠️ Caveat

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.

πŸ”„

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,
}
πŸ’‘ Element = description, not a thing

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

setState(next) 1 Β· SCHEDULE mark dirty, enqueue update β€” does NOT render yet 2 Β· RENDER re-run component β†’ new element tree (recurse) 3 Β· RECONCILE diff new tree vs previous β€” decide minimal changes 4 Β· COMMIT apply minimal changes to the REAL DOM 5 Β· PAINT browser draws the frame pure JS β€” no DOM, no reflow DOM touched β€” reflow ONCE here useLayoutEffect: sync before paint; useEffect: after
Render is pure & free-ish; only Commit touches the DOM. "Re-rendered" β‰  "DOM changed."

The two trees (double buffering)

App A B current on screen now (untouched during render) App A' B' workInProgress being built β€” safe to abort/discard alternate ⇄ alternate commit β†’ root.current = workInProgress (atomic swap)
Two trees linked by 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
}
πŸ“Œ Two DIFFERENT bugs, two different causes
SymptomReal causeFix
"count didn't update on the next line" (logs 0)Stale closure β€” each render freezes its own count as a constDon'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 renderFunctional 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)
βœ… The precise truth

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

React ≀17 β€” leaky

Batched only inside React event handlers. Inside setTimeout/Promise.then/native handlers, each setState = its own render+commit+reflow.

React 18 β€” automatic batching

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.

🧩

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?

⚠️ Consequence

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.

❌ key = index (reorder bug)
{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!
βœ… key = data identity
{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.
πŸ’‘ key as a deliberate reset

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.

βœ… Unifying takeaway

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.

🧡

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

πŸ’‘ The core idea

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 return App Header Logo Nav Main List Walk order (DFS): App β†’ Header β†’ Logo β†’ Nav β†’ Main β†’ List child ↓, else sibling β†’, else return ↑ & try sibling
A loop follows these pointers (no call stack) β€” so it can stop after any node and resume from the saved cursor.

Two phases, very different rules

⏸ RENDER phase interruptible Β· PURE Β· no side effects β€’ run components β†’ build WIP tree β€’ diff children, set effect flags β€’ can pause / resume / abort / restart β€’ may run multiple times β†’ must be pure (StrictMode double-invokes here in dev) tree done πŸ”’ COMMIT phase atomic Β· SYNC Β· side effects run β€’ apply DOM mutations (one pass) β€’ current = workInProgress (swap) β€’ refs + useLayoutEffect (sync) β€’ schedule useEffect (after paint) (NOT interruptible β€” half DOM = corrupt)
Pause/abort is safe only in Render. Once Commit starts mutating the real DOM, it must finish.
⚠️ Why render must be pure

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.

πŸ’‘ Yield β‰  return, and they didn't use generators

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();
ToyReal React
child/sibling/returnthe fiber linked list (reified call stack)
nextUnitOfWork module varthe 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
βœ… One-line synthesis

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.)

πŸͺ

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.

fiber.memoizedState idx 0 Β· useStatename idx 1 Β· useStateemail idx 2 Β· useEffectsubscribe null matched purely by call order (index) β€” never by name βœ… stable order idx 0 β†’ name idx 1 β†’ count ❌ hook behind an if β†’ name skipped idx 0 β†’ count(was idx 1) count now reads name's stored slot 0 β†’ corrupted state
The index is the only identity. That single fact is the whole reason for the Rules of Hooks.
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];
}
⚠️ Why no conditional hooks (the #1 Rule of Hooks)

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

useState

value + setter; mutate via setter β†’ triggers render. For things the UI is a function of.

useRef

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

time β†’ COMMIT: mutate DOM (reflow happens) useLayoutEffect sync Β· BLOCKS paint 🎨 PAINT user sees frame useEffect async Β· after paint Β· default measureβ†’mutate? use layout effect
useLayoutEffect runs before paint (kills flicker, but blocks). useEffect runs after paint (default, non-blocking).

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.

ToolMemoizesUse 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 referenceexpensive 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
⚠️ The trap

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.

πŸ’‘ Measure first β€” use the Profiler

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.

πŸ—“οΈ

React 16 (2017) β€” "rebuild the foundation"

The Fiber rewrite. Headline features are all things the stack reconciler couldn't do:

FeatureWhy Fiber enabled it
Error BoundariesFiber can unwind a subtree and render a fallback
Fragments <></>a fiber's child/sibling list allows multiple roots β€” no wrapper div
Portalsdecouples 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:

React 19 (2024) β€” "product features on top of concurrency"

The whole arc on one page

≀15 16 Β· 2017 18 Β· 2022 19 Β· 2024 Stack reconciler recursive walk can't pause blocks main thread βš™ FIBER ships error boundaries fragments Β· portals Hooks (16.8) machinery built CONCURRENCY ON createRoot transitions Β· batching Suspense + streaming powers switched on Product features Actions Β· use() RSC + Server Actions React Compiler built on concurrency One 2017 decision (reify the call stack as an interruptible list) β†’ everything downstream
16 built Fiber Β· 18 turned on its concurrency Β· 19 ships the patterns concurrency makes possible.
βœ… The sentence that ties it all together

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.

βˆ‘
🧠 The mental model in one breath

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.

State changesetState Virtual DOMre-run components Reconcilediff = what changed Commitreal DOM, once 🎨 Painton screen βš™ all orchestrated by React Fiber (pausable Β· prioritized) left of Commit = pure JS, free-ish Β· Commit = the only expensive, screen-touching step

One line per concept

ConceptIn one sentence
Declarative modelYou describe the destination (UI = f(state)), not the path β€” so you reason about states, not transitions.
Virtual DOMA cheap JS copy of the UI React diffs against; it's a staging area, not a speed trick.
ReconciliationThe process of comparing new vs old UI to decide the minimal DOM changes.
DiffingThe O(n) heuristics inside reconciliation: same type or rebuild, and key for list identity.
React FiberThe call stack reified as an interruptible linked list β€” so rendering can pause, resume, prioritize and abort.
Render phaseRe-run components, build the tree, compute the diff. Pure, interruptible, no DOM.
Commit phaseApply the changes to the real DOM in one atomic pass β€” the only step that reflows.
ReflowBrowser recomputing geometry; React minimizes it via batching + minimal, coordinated commits.
BatchingMany 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.
HooksOrdered slots on the fiber, matched by call order β€” which is the whole reason for the Rules of Hooks.
ConcurrencyFiber's pausability used to prepare/prioritize multiple updates so urgent ones (typing) never block.
πŸ’‘ The sentence that ties it all together

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.