⚛ The Course  /  Module 9 · Practice & Debugging · 9.1
Module 9 · Practice & Debugging

9.1 Worked examples

So far you've met the parts of the engine one at a time: fibers, lanes, the work loop, the bailout, the Scheduler, Suspense. This lesson runs them together on real components. The goal is a superpower: given a snippet and a user action, you can predict which fiber re-renders, which one bails out, and exactly when — instead of guessing.

How to read an engine trace

Every example below uses the same small vocabulary. Learn it once and all five traces become readable.

A timestamp like T=5ms means "here is what the engine is doing five milliseconds in." A lane is a priority written as bits (Module 3): SyncLane = 0b0001 is the most urgent, and a deferred TransitionLane looks like 0b1000. Lower bit means higher priority.

renderLanes is the set of lanes the current render is allowed to touch. For each fiber, beginWork asks one question: includesSomeLane(fiber.lanes, renderLanes) — "does this fiber carry an update in a lane I'm rendering right now?" That single question decides everything.

beginWork(fiber, renderLanes)
   includesSomeLane(fiber.lanes, renderLanes) ?
        YES → re-render this fiber
        NO  → bailout (skip it and its subtree)

Keep that picture in mind. Most of what looks like clever React behavior is just this check, repeated fiber by fiber, with different lanes selected each pass.

Example 1 — urgent input, deferred results

A search box filters a list of 10,000 items as you type. We want the keystroke to feel instant, even though filtering the list is expensive. startTransition is the tool: it lets one keypress split into two streams of work at two different priorities.

The real code

function SearchApp() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  function handleInput(e) {
    setQuery(e.target.value);              // High priority — SyncLane
    startTransition(() => {
      setResults(expensiveFilter(e.target.value));  // Low priority — TransitionLane
    });
  }

  return (
    <>
      <input value={query} onChange={handleInput} />
      <SearchBox query={query} />
      {results.map(item => <ResultItem key={item.id} item={item} />)}
    </>
  );
}

Here is the fiber tree React manages — one input, one box, and a long list:

<SearchApp>
├─ <input>
├─ <SearchBox>
└─ [<ResultItem>, <ResultItem>, ... 10,000 items]

Type 'r' — two updates, two lanes

One keystroke creates two updates. React ORs both lanes onto SearchApp, but it only stamps the relevant lane onto each branch below.

Lane assignment after the event

setQuery('r')             // Lane: SyncLane      (0b0001)
setResults(filter('r'))   // Lane: TransitionLane (0b1000)

// Fibers marked:
<SearchApp>.lanes  = 0b1001   // both updates
<input>.lanes      = 0b0001   // only query
<SearchBox>.lanes  = 0b0001   // only query
<ResultItem 0>.lanes = 0b1000 // only results
// ... every ResultItem: 0b1000

ensureRootIsScheduled looks at pendingLanes = 0b1001, picks the highest-priority bit (SyncLane), and schedules the urgent pass first.

The urgent pass — SyncLane only

With renderLanes = 0b0001, walk the tree and apply the one check. The input and SearchBox match; every ResultItem does not.

Work loop trace — renderLanes = 0b0001

performSyncWorkOnRoot(root)
  renderRootSync(root, 0b0001)
    workLoopSync()

      // SearchApp has lane 0b1001; includesSomeLane(0b1001, 0b0001) = true → RE-RENDER
      beginWork(<SearchApp>, 0b0001)

      // input has lane 0b0001; includesSomeLane(0b0001, 0b0001) = true → RE-RENDER
      beginWork(<input>, 0b0001)      // renders with query='r'

      // SearchBox has lane 0b0001 → RE-RENDER
      beginWork(<SearchBox>, 0b0001)  // renders with query='r'

      // ResultItem 0 has lane 0b1000; includesSomeLane(0b1000, 0b0001) = false
      // ✅ BAILOUT — no update in this lane
      bailoutOnAlreadyFinishedWork()
      // workInProgress = null — loop exits

  commitRoot(...)  // DOM: input shows 'r', results unchanged

The deferred pass — and getting interrupted

A few milliseconds later the Scheduler fires a Normal-priority task for the transition. Now renderLanes = 0b1000, so the input and SearchBox bail out (their work is already committed) and the ResultItems render. But this pass is interruptible: when the 5ms time-slice runs out, shouldYield() returns true and React stops partway, holding a pointer to where it left off.

Show the concurrent render trace (interrupted at item 101)

Concurrent render — renderLanes = 0b1000

renderRootConcurrent(root, 0b1000)
  prepareFreshStack(root, 0b1000)
    workInProgress = <SearchApp>
    workInProgressRootRenderLanes = 0b1000

  workLoopConcurrent()
    // SearchApp has lane 0b1001; includesSomeLane(0b1001, 0b1000) = true → re-render

    // input: includesSomeLane(0b0001, 0b1000) = false → BAILOUT (already committed)
    bailoutOnAlreadyFinishedWork() // <input>

    // SearchBox: same — BAILOUT
    bailoutOnAlreadyFinishedWork() // <SearchBox>

    // ResultItem 0: includesSomeLane(0b1000, 0b1000) = true → RENDER
    // ResultItem 1 → RENDER ... ResultItem 100 → RENDER

    shouldYield() === true  // 5ms budget exhausted!
    return RootInProgress   // INTERRUPTED — workInProgress = <ResultItem 101>

Now the user types 'e' while that transition is still mid-flight. Two new updates land — and the new SyncLane outranks the in-flight transition, so React drops what it was doing and serves the keystroke first.

setQuery('re')          // SyncLane (0b0001)
setResults(filter('re')) // NEW TransitionLane (0b10000) — React picks a fresh lane
Show how the in-flight work is thrown away (prepareFreshStack)

prepareFreshStack discards the in-flight work

renderRootSync(root, 0b0001)
  // lanes changed: 0b1000 → 0b0001
  prepareFreshStack(root, 0b0001)
    resetWorkInProgressStack()
      // Unwinds ResultItem 101 → ... → ResultItem 0 → SearchBox → input → SearchApp
      // ALL PARTIAL WORK FROM T=5ms IS DISCARDED

    workInProgress = <SearchApp>  // start over

  workLoopSync()
    // SearchApp → re-render with query='re'
    // input    → re-render with query='re'
    // SearchBox → re-render with query='re'
    // ResultItem 0: includesSomeLane(0b10000, 0b0001) = false → BAILOUT

  commitRoot(...)  // DOM: input shows 're', results still old

With no further keystrokes, the Scheduler picks up both pending transition lanes (0b1000 | 0b10000 = 0b11000) in one pass, and all 10,000 ResultItems render and commit together. The whole story on one timeline:

Time:    0ms     1ms    5-10ms   20ms   21ms   30ms
         │        │     │         │       │      │
SyncLane [Render]✓      IDLE      [Render]✓     IDLE
TransitionLane    [Render .......]✂INTERRUPT     [Render ..........done]✓

DOM:     query='' → 'r' (1ms)
                         → still 'r' results
                                   → 're' (21ms)
                                                 → 're' results (30ms)

Example 2 — a click that stays snappy under a flood

Same idea, simpler shape. A button bumps a counter (urgent) and builds a 10,000-item array (deferred). The new thing to watch here is what happens to the deferred update during the urgent pass: it is skipped, but not lost.

The real code

function Counter() {
  const [count, setCount] = useState(0);
  const [items, setItems] = useState([]);

  const handleClick = () => {
    setCount(c => c + 1);  // SyncLane — immediate

    startTransition(() => {
      setItems(Array(10000).fill(0).map((_, i) => i));  // TransitionLane — deferred
    });
  };

  return (
    <div>
      <button onClick={handleClick}>Count: {count}</button>
      <div>{items.length} items</div>
    </div>
  );
}

One click — skip but keep

After the click, Counter.lanes = 65 (0b1000001 — SyncLane and TransitionLane1 both set). The SyncLane pass renders with renderLanes = 1. Watch hook 2: its update is on lane 64, which is not a subset of 1, so React keeps the old state and re-marks the lane for later.

SyncLane render — updateReducer skips the transition update

// renderLanes = 1 (SyncLane)

// Hook 1 — count
// Update queue: [{ lane: 1, action: c => c + 1 }]
// isSubsetOfLanes(renderLanes=1, updateLane=1)? YES
// newState = 1  ✓

// Hook 2 — items
// Update queue: [{ lane: 64, action: [...10000 items] }]
// isSubsetOfLanes(renderLanes=1, updateLane=64)? NO (0b0000001 & 0b1000000 = 0)
// SKIP — keep old state: items = []
// clone update onto fiber.lanes |= 64 for next pass

commitRoot()  // DOM: "Count: 1", "0 items"
// remainingLanes = 64 → schedules NormalPriority task

Because lane 64 remains pending, the Scheduler runs a second pass. Now the kept update is processed, and the array appears.

Show the follow-up TransitionLane render

TransitionLane render — now processes the deferred update

// renderLanes = 64 (TransitionLane1)

// Hook 1 — count: no pending update → count = 1 (unchanged)

// Hook 2 — items
// isSubsetOfLanes(renderLanes=64, updateLane=64)? YES
// newState = [0, 1, 2, ... 9999]  ✓

commitRoot()  // DOM: "Count: 1", "10000 items"

Three rapid clicks — interruption cascade

If the user clicks three times quickly, each click serves its SyncLane immediately and restarts the transition. No count is dropped, and the kept item-updates pile up in the hook's queue until the transition finally finishes.

t=0ms    Click 1: SyncRender → Count:1, 0 items  ✓
         TransitionRender starts . . .
t=50ms   Click 2 arrives — SyncLane < TransitionLane
         ✂ INTERRUPT transition
         SyncRender → Count:2, 0 items  ✓
         TransitionRender restarts . . .
t=100ms  Click 3 arrives
         ✂ INTERRUPT again
         SyncRender → Count:3, 0 items  ✓
         TransitionRender restarts (all 3 queued actions applied in order)
t=200ms  TransitionRender completes → Count:3, 10000 items  ✓

After three clicks the items hook queue holds three entries (one per click). During the final transition render all three are processed sequentially — each overwrites the previous — yielding the same [0…9999] array. The final DOM state is consistent: the highest count and the correct items.

Example 3 — Suspense: throw a Promise, show the fallback

Now trace a different mechanism. When data is not ready, a component throws a Promise mid-render. React catches it, shows a fallback, and re-renders once the Promise resolves. Wrapping the swap in startTransition keeps the old screen visible meanwhile — a smooth navigation instead of a flash of "Loading...".

The real code

function fetchUser(id) {
  let status = 'pending';
  let result;
  const suspender = fetch(`/api/user/${id}`)
    .then(r => r.json())
    .then(data  => { status = 'success'; result = data; })
    .catch(err  => { status = 'error';   result = err;  });

  return {
    read() {
      if (status === 'pending') throw suspender;  // throws Promise
      if (status === 'error')   throw result;
      return result;
    }
  };
}

function UserProfile({ resource }) {
  const user = resource.read();  // may throw
  return <div>{user.name}</div>;
}

function App() {
  const [userId,   setUserId]   = useState(1);
  const [resource, setResource] = useState(() => fetchUser(1));

  const handleClick = () => {
    const nextId = userId + 1;
    setUserId(nextId);
    startTransition(() => {
      setResource(fetchUser(nextId));  // TransitionLane — show stale UI while loading
    });
  };

  return (
    <div>
      <button onClick={handleClick}>Next User</button>
      <Suspense fallback={<div>Loading...</div>}>
        <UserProfile resource={resource} />
      </Suspense>
    </div>
  );
}

Trace one click of "Next User" from the keystroke all the way to the resolved render. The key beat is at T=5ms: the transition render swaps in the new (pending) resource, read() throws, and React unwinds to the boundary and shows the fallback.

T=0ms   User clicks "Next User"
        setUserId(2)           → SyncLane  (0b0001)
        setResource(fetchUser) → TransitionLane (0b1000000)

T=1ms   SyncLane render
        App: userId=2 applied; resource = OLD (TransitionLane skipped)
        UserProfile reads OLD resource → returns cached data → shows "User 1"
        commitRoot() — DOM: "User 1" (no flicker)

T=5ms   TransitionLane render
        App: resource = NEW (freshly created, status='pending')
        UserProfile.read() → THROWS Promise

T=6ms   throwException() catches the SuspenseException
        Finds nearest Suspense boundary
        boundary.flags |= ShouldCapture  (becomes DidCapture on unwind)
        boundary.updateQueue.add(promise)
        attachPingListener(root, promise, lanes)

T=7ms   unwindWork() walks back up the tree to the Suspense fiber
        updateSuspenseComponent() with DidCapture flag:
          Creates Offscreen fiber (hidden) wrapping real children
          Creates fallback Fragment wrapping <div>Loading...</div>
        commitRoot() — DOM: "Loading..."

T=500ms Promise resolves (network done)
        pingSuspendedRoot(root, promise, lanes) called
        ensureRootIsScheduled(root) → new render scheduled

T=501ms Third render
        UserProfile.read() returns { name: 'User 2' }  (status='success')
        Suspense: no DidCapture → returns Offscreen in visible mode
        commitRoot() — DOM: "User 2"

The whole dance is driven by two flags on the boundary fiber. ShouldCapture is set when the Promise is thrown; the unwind converts it to DidCapture, which is the signal to render the fallback:

The key flag transitions inside Suspense

// throwException sets:
boundary.flags |= ShouldCapture;  // = 0x10000 = 65536

// unwindWork converts ShouldCapture → DidCapture:
fiber.flags = (flags & ~ShouldCapture) | DidCapture;  // 0x80 = 128

// updateSuspenseComponent checks:
const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;
if (didSuspend) {
  // render fallback subtree
}

Example 4 — when bailout fires (and when it can't)

React's default is to re-render a child whenever its parent renders. The bailout path is an opt-in shortcut that skips that work — but only when all four of its conditions hold at once. The most common way to accidentally break it is to hand a child a brand-new object on every render.

The problem — inline object breaks bailout

function ParentComponent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>

      {/* data prop is a NEW object literal every render */}
      <ExpensiveChild data={{ items: [1, 2, 3] }} />
    </div>
  );
}

On every click, JSX builds a fresh { items: [1,2,3] }. The contents match but the reference is new, so the props-equality check fails and the child renders anyway:

beginWork(<ExpensiveChild>)
  oldProps.data !== newProps.data   // true — different object reference
  → RENDER (no bailout)
  → heavyCalculation() called every click

The fix is to make the reference stable. React.memo turns on the props-equality check; useMemo ensures the object is the same one between renders:

The fix — stable references via memo + useMemo

const MemoizedChild = React.memo(function ExpensiveChild({ data }) {
  const result = heavyCalculation(data);
  return <div>{result}</div>;
});

function ParentComponent() {
  const [count, setCount] = useState(0);
  const stableData = useMemo(() => ({ items: [1, 2, 3] }), []);  // created once

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <MemoizedChild data={stableData} />
    </div>
  );
}
Show the bailout trace with React.memo (the four checks)

Bailout trace with React.memo (SimpleMemoComponent path)

beginWork(<MemoizedChild>, renderLanes)
  // Check 1: current !== null?  YES — this is an update, not a mount

  // Check 2: props changed?
  //   oldProps.data === newProps.data  (stableData reference is the same)
  //   shallowEqual passes → props unchanged

  // Check 3: no lane update on this fiber
  //   includesSomeLane(fiber.lanes, renderLanes) = false

  // Check 4: no DidCapture flag

  // ALL 4 pass → BAILOUT
  return bailoutOnAlreadyFinishedWork()
  // heavyCalculation NOT called

The same logic applies to inline functions passed as props — every render creates a new function reference, breaking the prop-equality check. useCallback stabilizes the reference the same way useMemo stabilizes objects. Here are all four conditions in one place:

┌─────────────────────────────────────────────────────┐
│ BAILOUT — all four must be true                    │
├─────────────────────────────────────────────────────┤
│ 1. Props unchanged (reference or shallowEqual)     │
│    oldProps === newProps  OR  shallowEqual passes   │
│                                                     │
│ 2. Context unchanged                               │
│    checkIfContextChanged(dependencies) === false    │
│                                                     │
│ 3. No lane update on this fiber                    │
│    !includesSomeLane(fiber.lanes, renderLanes)      │
│                                                     │
│ 4. No error capture flag                           │
│    (fiber.flags & DidCapture) === NoFlags          │
│                                                     │
│ IF ANY FAILS → component function body executes    │
└─────────────────────────────────────────────────────┘
Without optimization:
  click → 3× ExpensiveChild renders → 3× heavyCalculation → ~150ms, janky

With React.memo + useMemo:
  click → 3× BAILOUT → 0× heavyCalculation → ~5ms, instant
  Savings: ~97% render time reduction

Example 5 — debugging a surprise re-render

Now put it to work. A TodoList re-renders on every keystroke in an unrelated input, even though its todos never change. This is not a bug in React — it is the default rule (parent renders → children render) meeting a component that never opted out. The bailout conditions turn the diagnosis into a checklist.

The bug — TodoList re-renders on every keystroke

function TodoList({ todos }) {
  console.log('TodoList rendered!');  // fires on every keystroke!
  return (
    <ul>
      {todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}
    </ul>
  );
}

function App() {
  const [text,  setText]  = useState('');
  const [todos, setTodos] = useState([]);

  return (
    <div>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setTodos([...todos, { id: Date.now(), text }])}>
        Add
      </button>
      <TodoList todos={todos} />
    </div>
  );
}

Work the problem in five steps. The twist at step 3: all four bailout conditions actually pass — but a plain FunctionComponent (tag 0) never takes the bailout branch, because only a SimpleMemoComponent (tag 15) runs the props check. No opt-in, no bailout.

1. OBSERVE
   console.log fires on every keystroke even though todos hasn't changed.

2. IDENTIFY — why does TodoList render?
   TodoList has no state of its own.
   todos reference is unchanged (setTodos was not called).
   BUT: App re-renders on each keystroke (text state changes).
   Default behavior: parent renders → all children render.
   TodoList is NOT wrapped in React.memo → NO bailout opt-in.

3. ANALYZE — bailout checklist for TodoList
   Condition 1: props unchanged?  todos ref is the SAME → PASS
   Condition 2: context?          no context → PASS
   Condition 3: lane update?      no → PASS
   Condition 4: error flag?       no → PASS
   ──────────────────────────────────────────────
   All four pass, but TodoList is a plain FunctionComponent (tag=0),
   not a SimpleMemoComponent (tag=15).
   beginWork takes the FunctionComponent path — no shallowEqual check.
   The component body always executes.

4. FIX
   const TodoList = React.memo(function TodoList({ todos }) {
     console.log('TodoList rendered!');
     return ...;
   });

5. VERIFY
   Keystroke: no log (todos unchanged → bailout)
   Add button: one log (todos reference is new → renders)

When you are not sure why something rendered, this hook prints the answer — which prop changed, or "parent re-render" when none did:

Show the useWhyDidYouRender diagnostic hook

A diagnostic hook for any component

function useWhyDidYouRender(name, props) {
  const previousProps = useRef();

  useEffect(() => {
    if (previousProps.current) {
      const changedProps = Object.keys(props).filter(
        key => !Object.is(previousProps.current[key], props[key])
      );

      if (changedProps.length > 0) {
        console.log(`${name} re-rendered due to:`, changedProps);
        changedProps.forEach(key => {
          console.log(`  ${key}:`, {
            from: previousProps.current[key],
            to: props[key],
          });
        });
      } else {
        console.log(`${name} re-rendered but props unchanged (parent re-render)`);
      }
    }
    previousProps.current = props;
  });
}

// Usage — drop into any component under investigation:
function TodoList({ todos }) {
  useWhyDidYouRender('TodoList', { todos });
  // ...
}

When the log says "props unchanged (parent re-render)" the fix is always the same: React.memo on the component, and make any object/function props reference-stable. The five usual suspects:

Show the five common re-render causes and their fixes

Common re-render causes and their fixes

CAUSE 1 — Inline object props
  BAD:  <Child style={{ color: 'red' }} />
  GOOD: const style = useMemo(() => ({ color: 'red' }), []);
        <Child style={style} />

CAUSE 2 — Inline function props
  BAD:  <Child onClick={() => doSomething()} />
  GOOD: const handleClick = useCallback(() => doSomething(), []);
        <Child onClick={handleClick} />

CAUSE 3 — Spreading creates a new array/object
  BAD:  <Child items={[...items]} />   // new array every render
  GOOD: <Child items={items} />

CAUSE 4 — Unmemoized child in rendering parent
  BAD:  function Child(props) { ... }
  GOOD: const Child = React.memo(function Child(props) { ... });

CAUSE 5 — Context value is a new object each render
  BAD:  <Provider value={{ user, setUser }}>
  GOOD: const value = useMemo(() => ({ user, setUser }), [user]);
        <Provider value={value}>