A complete mental model of how React works under the hood — from the Virtual DOM to Fiber scheduling. Built for engineers preparing for staff-level interviews.
- The Problem React Solves
- Virtual DOM — What It Actually Is
- React Elements vs. Components vs. Fiber Nodes
- Reconciliation — The Core Algorithm
- Diffing Rules — The 3 Heuristics
- Keys — Why They Matter Internally
- Fiber Architecture — The Complete Rewrite
- Fiber Node Anatomy
- The Two-Phase Render
- Work Loop & Scheduling
- Lanes — Priority Model
- Concurrent Mode &
useTransition - Hooks Internals
- Re-render Decision Tree
- Common Interview Questions — Answered from Internals
Before React, developers manually synchronized application state to the DOM. Every state change required hand-written DOM mutations: document.getElementById, innerHTML, appendChild, removeChild. This was error-prone, hard to optimize, and impossible to reason about at scale.
React's core insight is simple: describe what the UI should look like for a given state, and let the framework figure out the minimum DOM mutations required to get there.
This requires two things: a way to represent the desired UI in memory (the Virtual DOM), and an algorithm to compare the previous and next representations and derive the cheapest set of real DOM mutations (reconciliation).
Your JSX → React Element Tree (VDOM) → Reconciler → Real DOM mutations
Everything in React internals is in service of making this pipeline fast, interruptible, and correct.
The "Virtual DOM" is a frequently misunderstood term. It is not a magic performance layer. It is simply a plain JavaScript object tree that describes the UI.
When you write JSX, Babel transforms it into React.createElement() calls (or in React 17+, the automatic JSX transform calls _jsx()):
// You write:
const element = <div className="card"><h1>Hello</h1></div>;
// Babel transforms to:
const element = React.createElement(
'div',
{ className: 'card' },
React.createElement('h1', null, 'Hello')
);The result is a plain object — a React Element:
{
$$typeof: Symbol(react.element), // tamper protection
type: 'div',
key: null,
ref: null,
props: {
className: 'card',
children: {
$$typeof: Symbol(react.element),
type: 'h1',
key: null,
ref: null,
props: { children: 'Hello' },
}
}
}This object is cheap to create and lives entirely in JavaScript memory. React then compares this tree to the previous one to compute DOM mutations — which is far cheaper than comparing actual DOM nodes (which carry hundreds of properties each).
YOUR JSX
│
React.createElement()
│
▼
┌──────────────────────────────┐
│ React Element Tree │ ← Plain JS objects in memory
│ │
│ { type: 'div', │
│ props: { │
│ className: 'card', │
│ children: [ │
│ { type: 'h1', │
│ props: { │
│ children: 'Hi' │
│ }} │
│ ] │
│ }} │
└──────────────┬───────────────┘
│
Reconciler compares
prev tree ↔ next tree
│
▼
┌──────────────────────────────┐
│ Real DOM Mutations │ ← Minimum changes only
│ e.g., setAttribute( │
│ 'className', 'card') │
└──────────────────────────────┘
Critical misconception: The VDOM does not make React faster than direct DOM manipulation in all cases. For simple, targeted updates, direct DOM manipulation can be faster. The VDOM's value is developer ergonomics at scale — you never manually track which DOM nodes need updating.
These three things are often conflated. They are distinct with different lifecycles.
| Concept | What it is | Created when | Lives where |
|---|---|---|---|
| React Element | Plain JS object describing a node | Every render call | Discarded after reconciliation |
| Component | A function or class that returns elements | Defined by you | In your source code |
| Fiber Node | React's internal work unit tracking a component instance | First mount | Persists in memory, reused across renders |
Component (your function)
│
│ called by React during render
▼
React Element (output of your function)
│
│ reconciler maps to
▼
Fiber Node (React's internal tracking record)
│
│ committed to
▼
Real DOM Node
The key insight: React Elements are cheap and temporary. Fiber Nodes are expensive and persistent. React reuses fiber nodes across renders to avoid re-allocating memory for every update.
Reconciliation is the process of comparing the previous React Element tree to the new one and computing the minimum set of changes to apply to the real DOM.
The naive approach — comparing every node against every other node — would be O(n³), completely impractical for real UIs with thousands of nodes. React uses a set of heuristics to bring this down to O(n).
State/Props change
│
▼
React calls your component function
│
▼
New React Element tree produced
│
▼
Reconciler walks both trees simultaneously
(current fiber tree ↔ new element tree)
│
┌──┴──────────────────────────────────┐
│ │
Same type? Different type?
│ │
▼ ▼
Update the existing Unmount old subtree entirely
fiber node with new props Mount brand new subtree
│ │
└──────────────┬──────────────────────┘
│
▼
Effect list of DOM mutations
│
▼
Commit phase
(Apply to real DOM)
React always maintains two fiber trees:
┌─────────────────────┐ ┌─────────────────────┐
│ Current Tree │ │ Work-in-Progress │
│ (what's on screen) │ ◄──────► │ Tree (being built) │
│ │ alternate│ │
│ fiber nodes with │ pointer │ fiber nodes being │
│ current state │ │ updated │
└─────────────────────┘ └─────────────────────┘
│ │
│ after commit: │
│ WIP becomes current ──────────┘
│ current becomes WIP (recycled)
This is called double buffering. React builds the next state in the WIP tree without touching the current tree (which the user can still see). On commit, it swaps them atomically.
React's O(n) reconciliation relies on three assumptions. Violating them causes performance problems.
If the root element type changes, React tears down the entire old subtree and builds a fresh one. It does not try to reuse any nodes.
Previous render: Next render:
<div> <span>
/ \ → / \
<h1> <p> <h1> <p>
(entirely new DOM nodes)
Practical implication: Never render a component conditionally using a wrapper that changes type. This forces full unmount/remount on every toggle.
// ❌ Bad — changes root type, unmounts everything each toggle
{isLoggedIn ? <div><UserPanel /></div> : <section><Login /></section>}
// ✅ Good — same root type, reconciles children
<div>{isLoggedIn ? <UserPanel /> : <Login />}</div>If two elements in the same position have the same type, React reuses the existing DOM node and only updates the changed attributes:
Previous: Next:
<div className="a"> → <div className="b">
Hello Hello
DOM operation: setAttribute('class', 'b')
No remount, no children re-evaluation beyond what changed.
For class components, the same instance is kept — componentDidUpdate fires, not componentWillUnmount + componentDidMount.
When reconciling children lists, React needs a way to know which old child maps to which new child. Without keys, it matches by position — which breaks when items are reordered.
Previous list: Next list (item A inserted at front):
key=B → <li>B key=A → <li>A ← React sees "position 0 changed B→A" → update
key=C → <li>C key=B → <li>B ← React sees "position 1 changed C→B" → update
key=C → <li>C ← React sees "new item at position 2" → create
With keys: React matches B→B (no-op), C→C (no-op), creates A. 2 fewer mutations.
Keys are not just a React warning. They are the mechanism React uses to preserve identity across renders.
During reconciliation of a list, React builds a map from key → existing fiber node. For each element in the new list, it looks up that key in the map. If found, it reuses the fiber (and thus the DOM node and component state). If not found, it creates a new fiber.
Existing fibers: New elements:
Map { [
'alice' → FiberNode, { key: 'bob', ... },
'bob' → FiberNode, { key: 'alice', ... },
'carol' → FiberNode, { key: 'carol', ... },
} ]
Resolution:
'bob' → reuse FiberNode (bob) — just reorder in DOM
'alice' → reuse FiberNode (alice) — just reorder in DOM
'carol' → reuse FiberNode (carol) — no change
// ❌ Dangerous with reorderable/filterable lists
{items.map((item, index) => <Item key={index} data={item} />)}When items reorder, their index changes. React sees different key → new item at that position → destroys and recreates the old fiber, losing its internal state (form input values, scroll position, animation state).
Keys must be stable (same across renders), unique among siblings, and derived from the data — not from index or Math.random().
// ✅ Stable, unique, data-derived
{users.map(user => <UserCard key={user.id} user={user} />)}Before Fiber (React ≤ 15), the reconciler was called the Stack Reconciler. It worked like a recursive function call: once started, it could not be interrupted. Every update had to process the entire component tree synchronously before returning control to the browser.
This made React unable to prioritize urgent work (user input) over non-urgent work (a large list re-render). The browser would drop frames, causing jank.
User types in input Large list re-renders
│ │
▼ ▼
React update starts ──────► Stack reconciler runs
│
Cannot be interrupted
│
Processes all 1000 nodes
│
~200ms later
│
Browser finally handles
the keypress
(user sees delayed input response)
Fiber reimplements reconciliation as a linked list of work units instead of a recursive call stack. Each fiber node represents one unit of work. React can pause after any unit, check if higher-priority work has arrived, and resume or abandon the current work.
Fiber Work Loop:
while (nextUnitOfWork) {
if (shouldYield()) {
// Higher priority work pending — pause here
scheduleCallback(resumeWork)
break
}
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
}
The name comes from computer science: a fiber is a unit of concurrency more granular than a thread — a manually scheduled coroutine. React's fibers are the same idea: lightweight, manually scheduled units of computation.
Every component instance in a mounted React tree has a corresponding Fiber Node — a JavaScript object that holds everything React needs to know about that component.
FiberNode {
// Identity
tag: FunctionComponent | ClassComponent | HostComponent | ...,
key: string | null,
type: Function | string, // your component fn or 'div'
// Tree structure (linked list, not array!)
return: FiberNode, // parent
child: FiberNode, // first child
sibling: FiberNode, // next sibling
// Double buffering
alternate: FiberNode, // the other tree's version of this node
// State & effects
memoizedState: Hook | null, // linked list of hook states
memoizedProps: object, // props from last render
pendingProps: object, // props for this render
// Work tracking
flags: Flags, // bitmask: Update | Placement | Deletion ...
lanes: Lanes, // priority bitmask
childLanes: Lanes, // priority of children's pending work
// DOM
stateNode: DOM Node | Component Instance | null,
// Effect list
updateQueue: UpdateQueue | null, // pending setState calls
}The fiber tree uses a left-child, right-sibling representation. This is what makes the tree traversable without a call stack.
App
/
▼
Header → Main → Footer
/
▼
Nav → Content
As a fiber linked list:
App.child = Header
Header.return = App
Header.sibling = Main
Main.return = App
Main.child = Nav
Nav.return = Main
Nav.sibling = Content
Content.return = Main
Main.sibling = Footer
Footer.return = App
React traverses this with a simple loop — no recursion, no call stack growth, fully pauseable at any node.
React's rendering pipeline has two distinct phases with fundamentally different properties.
┌────────────────────────────────────────────────────────────────┐
│ RENDER PHASE │
│ • Pure computation — no side effects │
│ • Can be interrupted, paused, restarted, or abandoned │
│ • Runs in the background (in concurrent mode) │
│ • Produces an effect list of mutations │
│ │
│ beginWork() → reconcile children → completeWork() │
│ (top-down) (bottom-up) │
└────────────────────────────────────────────┬───────────────────┘
│
Effect list ready
│
▼
┌────────────────────────────────────────────────────────────────┐
│ COMMIT PHASE │
│ • Synchronous — cannot be interrupted │
│ • Applies mutations to real DOM │
│ • Runs layout effects (useLayoutEffect) │
│ • Schedules passive effects (useEffect) │
│ │
│ Phase 1: beforeMutation (read DOM before changes) │
│ Phase 2: mutation (apply DOM changes) │
│ Phase 3: layout (run useLayoutEffect) │
│ │
│ After paint: │
│ Phase 4: passive effects (run useEffect) │
└────────────────────────────────────────────────────────────────┘
React walks the fiber tree depth-first using two functions:
beginWork(fiber) — Called on the way down. Determines if this fiber needs work. If yes, calls the component function, reconciles children, returns the first child to process next.
completeWork(fiber) — Called on the way back up after all children are done. Creates or updates the DOM node for this fiber. Bubbles effect flags up to the root.
Tree: App → Div → H1
└──→ P
Traversal order:
beginWork(App)
beginWork(Div)
beginWork(H1)
completeWork(H1) ← leaf node, go back up
beginWork(P)
completeWork(P) ← leaf node, go back up
completeWork(Div)
completeWork(App)
The commit phase is always synchronous. It processes the effect list (fibers flagged for mutation) in three passes:
Pass 1: beforeMutation
└── snapshot DOM state before changes (getSnapshotBeforeUpdate)
└── schedule useEffect cleanups
Pass 2: mutation ← This is where the DOM actually changes
└── insertions (Placement flag)
└── updates (Update flag)
└── deletions (ChildDeletion flag)
└── ref detachments
Pass 3: layout ← DOM is updated, paint hasn't happened yet
└── componentDidMount / componentDidUpdate
└── useLayoutEffect callbacks
└── ref attachments
After browser paint:
└── useEffect cleanups (from previous render)
└── useEffect callbacks (from this render)
Why useLayoutEffect fires before useEffect: Layout effects run synchronously after DOM mutation but before the browser has painted. This is why useLayoutEffect can read layout measurements (like getBoundingClientRect) without seeing a flash. useEffect runs asynchronously after paint — it cannot safely read layout before the browser has rendered.
The Fiber work loop is the engine that drives rendering. In concurrent mode, it cooperates with the browser's event loop rather than blocking it.
// Simplified — actual React source is more complex
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress)
}
}
function shouldYield() {
// React uses the scheduler package
// Returns true if the current time slice has expired
return getCurrentTime() >= deadline
}React gets a time slice from the browser (typically 5ms via MessageChannel). It processes fiber nodes until the slice is exhausted, then yields. The browser handles any pending input or paint, then React is scheduled to resume.
React does not use requestAnimationFrame for scheduling work — it uses MessageChannel, which fires in the macrotask queue at a higher frequency than rAF and does not depend on the display refresh rate.
Browser frame:
├── React work slice (5ms) ← MessageChannel callback
├── Input events handled ← browser
├── Render / Paint ← browser
├── React work slice (5ms) ← next MessageChannel callback
└── ...
In legacy mode (ReactDOM.render), the work loop is synchronous — workLoopSync does not call shouldYield(). The entire tree processes before returning to the browser. This is why React 17 apps can still have jank with large component trees.
React 18 introduced Lanes — a bitmask-based priority system that replaced the older ExpirationTime model. Each bit in a 31-bit integer represents a different priority lane.
Lane Priority (highest to lowest):
SyncLane = 0b0000000000000000000000000000001 ← Synchronous (click handlers)
InputContinuousLane = 0b0000000000000000000000000000100 ← Continuous input (drag, scroll)
DefaultLane = 0b0000000000000000000000000010000 ← Normal updates (setState)
TransitionLane = 0b0000000000000000000111111100000 ← useTransition updates
IdleLane = 0b0100000000000000000000000000000 ← Idle/background work
OffscreenLane = 0b1000000000000000000000000000000 ← Hidden content (Suspense)
Bitmasks allow React to check multiple lanes at once with cheap bitwise operations:
// Is any transition work pending?
const hasTransitionWork = (pendingLanes & TransitionLanes) !== 0
// Merge two sets of pending work
const merged = lanesA | lanesB
// Remove a lane after processing
const remaining = pendingLanes & ~processedLaneEvery state update is assigned a lane based on how it was triggered:
| Trigger | Lane |
|---|---|
ReactDOM.flushSync() |
SyncLane |
| Click, keyboard event | SyncLane |
| Continuous input (drag) | InputContinuousLane |
setTimeout, Promise |
DefaultLane |
startTransition() |
TransitionLane |
useDeferredValue |
TransitionLane |
| Offscreen (Suspense hidden) | OffscreenLane |
React always processes the highest-priority lanes first. Low-priority work (transitions) is deferred until high-priority lanes are empty.
Concurrent Mode is the feature that makes Fiber's interruptibility actually useful to developers.
Without concurrent features, every state update is synchronous and urgent. A slow re-render (e.g., filtering a list of 10,000 items) blocks user input until it finishes.
Wrapping a state update in startTransition assigns it to a TransitionLane — telling React this update is non-urgent. React can start the transition render, interrupt it if a more urgent update arrives (like user typing), and restart it when the urgent work is done.
const [isPending, startTransition] = useTransition()
function handleSearch(query) {
// Urgent: update input immediately (SyncLane)
setInputValue(query)
// Non-urgent: filter results (TransitionLane)
startTransition(() => {
setFilteredList(items.filter(i => i.name.includes(query)))
})
}User types "a"
│
├─► setInputValue('a') → SyncLane → renders immediately
└─► startTransition(setFilter) → TransitionLane → starts rendering...
User types "ab" (before transition finishes)
│
├─► setInputValue('ab') → SyncLane → interrupts transition, renders immediately
└─► React discards transition render for 'a', starts fresh for 'ab'
The interrupted transition render is thrown away entirely — React never commits a stale intermediate state. This is why transitions must be pure: React may run your component multiple times and only keep the last result.
Similar to useTransition but for derived values rather than update handlers:
const deferredQuery = useDeferredValue(query)
// deferredQuery lags behind query during transitions
// The expensive component that uses deferredQuery re-renders at low priorityHooks are not magic — they rely on a simple but strict implementation: a linked list of hook state stored on the fiber node.
Every fiber that uses hooks has a memoizedState field pointing to the head of a linked list. Each node in the list corresponds to one hook call, in the order they were called.
Fiber.memoizedState
│
▼
Hook { ──────── Hook { ──────── Hook { ──────── null
memoizedState: 0, memoizedState: '', memoizedState: false,
queue: UpdateQueue, queue: UpdateQueue, queue: null,
next: ──────────► next: ──────────► next: null
} } }
useState(0) useState('') useRef(false)
React identifies which hook state belongs to which hook by position in the linked list. If you conditionally call a hook, the list length changes between renders, and every hook after the conditional one reads from the wrong node.
// ❌ This corrupts the hooks list
function Component({ show }) {
const [a, setA] = useState(0) // Hook 1
if (show) {
const [b, setB] = useState('') // Hook 2 (sometimes)
}
const [c, setC] = useState(false) // Hook 2 or 3 depending on show!
}// Simplified implementation
function useState(initialState) {
const hook = getCurrentHook() // reads next node from the linked list
if (isFirstRender) {
hook.memoizedState = typeof initialState === 'function'
? initialState() // lazy initializer
: initialState
hook.queue = { pending: null, dispatch: null }
}
const dispatch = hook.queue.dispatch ?? (
hook.queue.dispatch = dispatchAction.bind(null, currentFiber, hook.queue)
)
return [hook.memoizedState, dispatch]
}Effects are stored in the fiber's updateQueue, not the hooks linked list. During the commit phase:
Commit phase (mutation):
→ Schedule effect cleanup (from previous render)
After paint (passive effects flush):
→ Run cleanup from previous render
→ Run new effect callback
→ Store cleanup function on the effect object
useEffect with [] runs once because React checks the dependency array against the previous one using Object.is() comparison. An empty array always matches an empty array — so after the first run, the effect never re-runs.
One of the most common interview questions: "When does React re-render a component?" Here is the complete decision tree.
Something changes
│
┌────────────┼────────────┐
│ │ │
setState Context Parent
called changes re-renders
│ │ │
▼ ▼ ▼
Is new state Does this Does this
=== old state? component component
│ consume have
YES this React.memo?
│ context? │
▼ │ NO YES
Bailout early YES │ │
(no re-render) │ │ │
▼ ▼ ▼
Re-render Re-render Do props
match?
(Object.is)
│
YES ────┴──── NO
│ │
Bailout Re-render
(no re-render)
React can bail out of rendering a subtree if:
-
React.memo— Wrapping a function component inReact.memocauses React to do a shallow prop comparison. If all props passObject.is(), the component does not re-render even if the parent does. -
useMemo/useCallback— These preserve referential equality of values/functions across renders, preventing unnecessary re-renders of memoized children. -
Same state value — If you call
setState(x)andx === currentState(byObject.is()), React bails out without re-rendering. -
shouldComponentUpdate/PureComponent— The class component equivalents.
Object.is(1, 1) // true → no re-render
Object.is('a', 'a') // true → no re-render
Object.is(null, null) // true → no re-render
Object.is({}, {}) // false → re-render! (different references)
Object.is([], []) // false → re-render! (different references)
Object.is(fn, fn) // true if SAME function referenceThis is why inline objects and functions in JSX break memoization:
// ❌ New object reference every render — React.memo is useless
<MemoizedChild style={{ margin: 10 }} onClick={() => doThing()} />
// ✅ Stable references — React.memo works
const style = useMemo(() => ({ margin: 10 }), [])
const onClick = useCallback(() => doThing(), [])
<MemoizedChild style={style} onClick={onClick} />The Virtual DOM is a JavaScript object tree that represents the UI. It is not inherently fast — direct DOM updates can be faster. The value is that React can compare two VDOM trees in pure JS (cheap) and derive the minimum set of real DOM mutations needed (avoiding expensive, unnecessary DOM operations). The speed comes from doing less DOM work, not from VDOM itself.
The process of comparing the previous React Element tree with the new one to determine what changed, then translating those changes into real DOM mutations. React uses three heuristics to do this in O(n): different root types = full remount; same type = update in place; lists = matched by key.
Fiber is React's reimplementation of the reconciler (shipped in React 16) using a linked list of work units instead of a recursive call stack. The motivation: the old stack reconciler could not be interrupted mid-render, causing dropped frames on large updates. Fiber makes rendering interruptible and prioritized — React can pause low-priority rendering, handle urgent work (like user input), and resume or discard the paused work.
Because { margin: 10 } !== { margin: 10 } — each render creates a new object reference. React's memoization uses Object.is(), which checks referential equality for objects. The new reference means React cannot confirm props are unchanged, so it re-renders. Fix with useMemo.
Both receive a callback that runs after render. The timing differs: useLayoutEffect runs synchronously after DOM mutation but before the browser paints — safe for reading layout measurements. useEffect runs asynchronously after the browser has painted — safer for non-visual side effects. Using useLayoutEffect for heavy work blocks the paint and causes visual delay.
Hydration is the process of attaching React's event system and component state to server-rendered HTML. The browser receives fully-rendered HTML (fast first paint), and then React runs client-side, walks the existing DOM, and connects event listeners and component state without re-creating DOM nodes. A hydration mismatch occurs when the server-rendered HTML does not match what React expects on the client — React logs a warning and falls back to client-side rendering for the mismatched subtree.
It makes rendering interruptible. In legacy mode, every setState triggers a synchronous render that the browser cannot interrupt. In concurrent mode, React renders in the background in time slices, yielding to the browser between slices. High-priority updates (user input) can interrupt low-priority renders (data display). useTransition and useDeferredValue are the developer-facing APIs to control this prioritization.
React schedules another render. The effect runs after paint, so the new render will also commit and paint — causing a second paint. This is usually visible as a flicker. If you need to update state synchronously before paint (to avoid flicker), use useLayoutEffect. If the state update in the effect is truly necessary, at least include the correct dependency array to avoid infinite loops.
JSX
│ Babel
▼
React Elements (plain JS objects — cheap, temporary)
│ React.createElement / JSX transform
▼
Fiber Nodes (persistent work units — one per component instance)
│ Reconciler
▼
Render Phase
├── beginWork (top-down: call components, reconcile children)
├── [interruptible in concurrent mode]
└── completeWork (bottom-up: create DOM nodes, bubble effects)
│
▼
Effect List (fibers flagged with mutations)
│ Commit Phase (always synchronous)
├── beforeMutation
├── mutation (real DOM changes happen here)
├── layout (useLayoutEffect fires here — before paint)
│
▼
Browser paints
│
▼
Passive effects (useEffect fires here — after paint)
Everything in React internals — the VDOM, diffing, Fiber, lanes, hooks linked list — exists to make this pipeline correct, fast, and responsive to user input.
Master this mental model and you can reason through any React behavior from first principles — which is exactly what staff-level interviews test.