Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save carefree-ladka/0e7ea65f4085860dee9a3e8365d392f4 to your computer and use it in GitHub Desktop.

Select an option

Save carefree-ladka/0e7ea65f4085860dee9a3e8365d392f4 to your computer and use it in GitHub Desktop.
React Internals: VDOM, Diffing, Reconciliation & Fiber

React Internals: VDOM, Diffing, Reconciliation & Fiber

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.


Table of Contents

  1. The Problem React Solves
  2. Virtual DOM — What It Actually Is
  3. React Elements vs. Components vs. Fiber Nodes
  4. Reconciliation — The Core Algorithm
  5. Diffing Rules — The 3 Heuristics
  6. Keys — Why They Matter Internally
  7. Fiber Architecture — The Complete Rewrite
  8. Fiber Node Anatomy
  9. The Two-Phase Render
  10. Work Loop & Scheduling
  11. Lanes — Priority Model
  12. Concurrent Mode & useTransition
  13. Hooks Internals
  14. Re-render Decision Tree
  15. Common Interview Questions — Answered from Internals

1. The Problem React Solves

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.


2. Virtual DOM — What It Actually Is

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

VDOM Diagram

                    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.


3. React Elements vs. Components vs. Fiber Nodes

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.


4. Reconciliation — The Core Algorithm

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

The High-Level Flow

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)

Current Tree vs. Work-in-Progress Tree

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.


5. Diffing Rules — The 3 Heuristics

React's O(n) reconciliation relies on three assumptions. Violating them causes performance problems.

Heuristic 1 — Different Types Produce Different Trees

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>

Heuristic 2 — Same Type, Update in Place

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.

Heuristic 3 — Lists Use Keys to Match Children

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.

6. Keys — Why They Matter Internally

Keys are not just a React warning. They are the mechanism React uses to preserve identity across renders.

How Keys Work Under the Hood

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

Why Index as Key is Dangerous

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

Key Stability Rule

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} />)}

7. Fiber Architecture — The Complete Rewrite

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.

Stack Reconciler — The Problem

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 — The Solution

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

Why "Fiber"?

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.


8. Fiber Node Anatomy

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
}

Fiber Tree Structure — Linked List, Not Array

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.


9. The Two-Phase Render

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)                      │
└────────────────────────────────────────────────────────────────┘

Render Phase — DFS Traversal

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)

Commit Phase — Three Sub-Phases

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.


10. Work Loop & Scheduling

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.

The Work Loop

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

Scheduling via MessageChannel

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
  └── ...

Legacy (Synchronous) Mode

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.


11. Lanes — Priority Model

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)

Why Bitmasks?

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 & ~processedLane

Lane Assignment

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


12. Concurrent Mode & useTransition

Concurrent Mode is the feature that makes Fiber's interruptibility actually useful to developers.

The Core Problem

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.

startTransition / useTransition

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

What Happens Internally

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.

useDeferredValue

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 priority

13. Hooks Internals

Hooks are not magic — they rely on a simple but strict implementation: a linked list of hook state stored on the fiber node.

The Hooks Linked List

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)

Why Hooks Cannot Be Called Conditionally

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!
}

useState Internals

// 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]
}

useEffect Internals

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.


14. Re-render Decision Tree

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)

Bailout Conditions

React can bail out of rendering a subtree if:

  1. React.memo — Wrapping a function component in React.memo causes React to do a shallow prop comparison. If all props pass Object.is(), the component does not re-render even if the parent does.

  2. useMemo / useCallback — These preserve referential equality of values/functions across renders, preventing unnecessary re-renders of memoized children.

  3. Same state value — If you call setState(x) and x === currentState (by Object.is()), React bails out without re-rendering.

  4. shouldComponentUpdate / PureComponent — The class component equivalents.

What Object.is() Means in Practice

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 reference

This 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} />

15. Common Interview Questions — Answered from Internals

"What is the Virtual DOM and why is it fast?"

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.

"What is reconciliation?"

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.

"What is Fiber and why was it introduced?"

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.

"Why does React re-render when I pass an inline object as a prop?"

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.

"What is the difference between useEffect and useLayoutEffect?"

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.

"What is hydration?"

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.

"What does concurrent mode actually change?"

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.

"What happens when you call setState inside useEffect?"

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.


Summary — The Mental Model

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment