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