Walkthrough: setState to Pixels
A click reaches React through the listener createRoot put on the container. dispatchDiscreteEvent sets the update priority to Discrete, so setCount gets SyncLane. The update is…
- Published
- Reading time
- 19 min read
This continues the app from First Render Walkthrough. The first render has committed, its passive effect has run, and the screen shows 0.
Starting state
function App() {
const [count, setCount] = useState(0);
useLayoutEffect(() => { document.title = `count: ${count}`; }, [count]);
useEffect(() => { console.log("committed", count); }, [count]);
return (
<div>
<Header title="Counter" />
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
<Footer />
</div>
);
}FiberRoot.current ─► HostRoot(B) HostRoot(A) = alternate (the old WIP from mount)
└ App alternate: null lanes: 0
└ div alternate: null
├ Header ─ h1 (all alternate: null; only the HostRoot pair exists so far)
├ button stateNode: <button>, __reactProps$.onClick = arrow#1
└ Footer ─ footer
App.memoizedState: Hook{0, queue{pending: null, lastRenderedState: 0, dispatch: setCount}} → Hook{layout} → Hook{passive}
root.pendingLanes: 0The whole flow
sequenceDiagram
autonumber
participant U as user
participant B as browser
participant L as root listener (react-dom)
participant H as your onClick
participant RC as reconciler
participant MT as microtask
participant WL as work loop
participant C as commit
U->>B: click <button>
B->>L: native click, capture then bubble on #root
L->>L: dispatchDiscreteEvent: p = DiscreteEventPriority, T = null
L->>L: dispatchEvent → getClosestInstanceFromNode → button fiber
L->>L: SimpleEventPlugin: accumulateSinglePhaseListeners('onClick')
L->>H: processDispatchQueue → listener(syntheticEvent)
H->>RC: setCount(c => c + 1) = dispatchSetState
RC->>RC: requestUpdateLane → SyncLane, eager state: 1 ≠ 0
RC->>RC: enqueueConcurrentHookUpdate (buffered), App.lanes = SyncLane
RC->>MT: scheduleUpdateOnFiber → ensureRootIsScheduled → queueMicrotask
L->>L: restore priority; listener returns
MT->>RC: processRootScheduleInMicrotask: SyncLane → no Scheduler task
RC->>WL: flushSyncWorkAcrossRoots → performSyncWorkOnRoot → renderRootSync
WL->>WL: prepareFreshStack: link update, HostRoot.childLanes = SyncLane
WL->>WL: HostRoot bails out → App renders (updateReducerImpl: 1)
WL->>WL: div, Header, h1, button, Footer, footer (no compiler: all re-render)
WL->>WL: completeWork: markUpdate on changed props (no diff yet)
WL->>C: completeRoot → commitRoot
C->>B: mutation: commitUpdate → updateProperties → button text "1"
C->>C: root.current = finishedWork, layout: document.title
C->>C: flushSpawnedWork: SyncLane → flushPendingEffects → useEffect NOW
B->>U: microtask ends, task ends → paint "1"Call stack at a glance
── browser task: native 'click' event ────────────────────────────────────
(root capture listener) dispatchDiscreteEvent → dispatchEvent → … no onClickCapture handlers
(root bubble listener) dispatchDiscreteEvent(click, flags, container, nativeEvent)
setCurrentUpdatePriority(DiscreteEventPriority) // ReactDOMSharedInternals.p = SyncLane
dispatchEvent
findInstanceBlockingEvent → getClosestInstanceFromNode(target) → button fiber
dispatchEventForPluginEventSystem → batchedUpdates
dispatchEventsForPlugins
extractEvents → SimpleEventPlugin.extractEvents
accumulateSinglePhaseListeners(buttonFiber, 'onClick') → [{button, arrow}]
processDispatchQueue → processDispatchQueueItemsInOrder → executeDispatch
onClick() // your arrow
setCount(c => c + 1) = dispatchSetState(AppFiber, queue, action)
requestUpdateLane → resolveUpdatePriority → SyncLane
dispatchSetStateInternal
eager: basicStateReducer(0, action) = 1; !Object.is(1, 0)
enqueueConcurrentHookUpdate // concurrentQueues, App.lanes |= SyncLane
scheduleUpdateOnFiber → markRootUpdated → ensureRootIsScheduled
ensureScheduleIsScheduled → scheduleImmediateRootScheduleTask → queueMicrotask
setCurrentUpdatePriority(previous)
── microtask checkpoint (listener returned) ──────────────────────────────
processRootScheduleInMicrotask
scheduleTaskForRootDuringMicrotask → SyncLane, callbackNode = null
flushSyncWorkAcrossRoots_impl → performSyncWorkOnRoot
flushPendingEffects // nothing left from the mount
performWorkOnRoot(root, SyncLane, forceSync=true) → renderRootSync
prepareFreshStack → createWorkInProgress(root.current) // reuses HostRoot(A)
finishQueueingConcurrentUpdates → markUpdateLaneFromFiberToRoot
workLoopSync → performUnitOfWork
beginWork(HostRoot) → attemptEarlyBailoutIfNoScheduledUpdate → bailoutOnAlreadyFinishedWork → cloneChildFibers
beginWork(App) → updateFunctionComponent → renderWithHooks(HooksDispatcherOnUpdate)
updateState → updateReducer → updateReducerImpl → markWorkInProgressReceivedUpdate
updateLayoutEffect / updateEffect → updateEffectImpl (deps changed)
reconcileChildFibers → reconcileSingleElement → useFiber(div)
beginWork(div) → updateHostComponent → reconcileChildrenArray → updateSlot → updateElement → useFiber ×3
beginWork(Header) → props !== → renderWithHooks → Header() // without compiler
beginWork(h1), beginWork(button), beginWork(Footer), beginWork(footer)
completeWork(…) → updateHostComponent (complete): oldProps !== newProps → markUpdate
finishConcurrentRender → completeRootWhenReady → completeRoot → commitRoot
flushMutationEffects → commitMutationEffectsOnFiber
HostComponent + Update → commitHostUpdate → commitUpdate → updateProperties → setProp
→ setTextContent(button, "1")
root.current = finishedWork
flushLayoutEffects → commitHookLayoutEffects // document.title = "count: 1"
flushSpawnedWork → requestPaint(); includesSyncLane → flushPendingEffects
flushPassiveEffects → flushPassiveEffectsImpl → commitPassiveMountEffects // console.log
── task ends → style / layout / paint ─────────────────────────────────────The steps
1. The click reaches the root container
The user clicks the <button>. The browser dispatches a native click that travels capture → target → bubble. React has no listener on the button. In step 2 of the first render, listenToAllSupportedEvents put a capture listener and a bubble listener for click on #root.1 The capture listener fires first, on the way down. It collects onClickCapture handlers, finds none, and dispatches nothing. The bubble listener fires on the way back up.
→ deep dive: Event System
2. dispatchDiscreteEvent: the priority is set around your handler
Each listener was created by createEventListenerWrapperWithPriority, which picked dispatchDiscreteEvent because getEventPriority('click') is DiscreteEventPriority.23 dispatchDiscreteEvent saves and nulls ReactSharedInternals.T (a click is never a transition), calls setCurrentUpdatePriority(DiscreteEventPriority), calls dispatchEvent, and restores both values in finally.4 This is a dynamic scope: anything that calls setState while it is active sees Discrete priority.
3. dispatchEvent: DOM node → fiber
dispatchEvent calls findInstanceBlockingEvent, which takes the event target and calls getClosestInstanceFromNode.56 That reads the __reactFiber$<random> expando that precacheFiberNode wrote in createInstance and returns the button fiber.7 There's no dehydrated boundary in the way, so nothing blocks the event, and dispatchEventForPluginEventSystem runs dispatchEventsForPlugins inside batchedUpdates.89
4. SimpleEventPlugin collects onClick
SimpleEventPlugin.extractEvents maps click → onClick, picks SyntheticMouseEvent, and calls accumulateSinglePhaseListeners(buttonFiber, 'onClick', …).1011 That walks fiber.return up to the root. For each host fiber it calls getListener, which reads the prop from the DOM node's __reactProps$ (the latest committed props), not from fiber.memoizedProps.12
button (HostComponent) onClick: arrow#1 ✔ collected
div (HostComponent) no onClick
App, HostRoot not host fibers: skipped
dispatchQueue = [{ event: SyntheticMouseEvent('onClick'), listeners: [{button, arrow#1}] }]5. Your handler runs
processDispatchQueue → processDispatchQueueItemsInOrder iterates the listeners target → root, and executeDispatch sets event.currentTarget and calls arrow#1(event).131415 Your code runs: setCount(c => c + 1).
6. dispatchSetState → requestUpdateLane = SyncLane
setCount is dispatchSetState.bind(null, AppFiber, queue) from mountState.16 dispatchSetState asks requestUpdateLane(fiber).1718 We aren't rendering and T is null, so the answer is eventPriorityToLane(resolveUpdatePriority()). ReactDOMSharedInternals.p is DiscreteEventPriority, which is SyncLane.1920
→ deep dive: setState, Lanes & Scheduler
7. The eager-state attempt
dispatchSetStateInternal builds {lane: SyncLane, action: c => c + 1, hasEagerState: false, eagerState: null, next: null}.21 Then it checks whether it can compute the next state now:
if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
const eagerState = queue.lastRenderedReducer(queue.lastRenderedState, action); // basicStateReducer(0, c => c + 1) = 1
update.hasEagerState = true;
update.eagerState = eagerState;
if (is(eagerState, currentState)) { /* enqueue with NoLane, return: no render at all */ }
}On this first click the App fiber has no pending lanes and no alternate, so the reducer runs. It returns 1, which differs from 0, so there's no bailout. The work isn't wasted, though. hasEagerState lets the render reuse 1 without calling your updater again (step 14).
The precondition matters. The eager result is only valid if no other update is queued ahead of this one. That has two consequences:
- A second
setStatein the same handler never eager-bails. Once one call has queued an update with a lane,fiber.lanes !== NoLanesand every later call goes straight to the queue. WithsetCount(c => c + 1); setCount(c => c - 1)the net result is0, but React can only find that out by renderingApp.updateReducerImplthen seesObject.is(0, 0), andupdateFunctionComponenttakes the late bailout (bailoutHooks). - On the next click it usually can't run at all. After this update commits, the other twin of the App fiber still has
lanes = SyncLane.beginWorkclearslaneson the WIP only, and nothing on the commit path clears the old one. Only a late bailout (bailoutHooksdoescurrent.lanes = removeLanes(...)) cleans it.22 So "same value ⇒ no render" is a best-effort fast path, never a guarantee.
→ deep dive: setState, Hooks on Update
8. Buffered in concurrentQueues
enqueueConcurrentHookUpdate pushes (AppFiber, queue, update, SyncLane) onto the module-level concurrentQueues array and sets App.lanes |= SyncLane (and on the alternate, if there were one).2324 It does not touch queue.pending or the ancestors' childLanes. That happens in step 12, in finishQueueingConcurrentUpdates, so that a render already in progress never sees half an event's updates. It returns the FiberRoot by walking .return.25
9. scheduleUpdateOnFiber → one microtask; the handler returns
scheduleUpdateOnFiber → markRootUpdated sets root.pendingLanes = SyncLane, then ensureRootIsScheduled adds the root to the schedule and ensureScheduleIsScheduled queues a microtask, unless one is already queued.2627282930 setCount returns. Your handler returns. dispatchDiscreteEvent restores the old priority. Nothing has rendered yet, and count in your closure is still 0.
10. Microtask: SyncLane means no Scheduler task
When the listener's call stack empties, the microtask runs processRootScheduleInMicrotask.31 scheduleTaskForRootDuringMicrotask finds SyncLane in getNextLanes. Instead of booking a Scheduler task it cancels any existing one, sets root.callbackPriority = SyncLane, callbackNode = null, and returns. The source comment reads "Synchronous work is always flushed at the end of the microtask".32 Then processRootScheduleInMicrotask calls flushSyncWorkAcrossRoots_impl, which calls performSyncWorkOnRoot for every root with sync lanes.33
Compare the first render: there, the microtask booked a NormalPriority Scheduler task and the render ran in a later macrotask. Here everything that follows happens inside this microtask, before the browser can render a frame.
11. performSyncWorkOnRoot → renderRootSync
performSyncWorkOnRoot first calls flushPendingEffects(). Leftover passive effects from the previous commit must run before a new render. They already ran in the first render's step 22, so there's nothing to do. Then it calls performWorkOnRoot(root, SyncLane, forceSync = true).34 forceSync alone makes shouldTimeSlice false, and SyncLane is blocking anyway, so we get renderRootSync.3536
→ deep dive: Work Loop
12. prepareFreshStack: the update is linked and childLanes set
prepareFreshStack calls createWorkInProgress(root.current, null). This time HostRoot(B) has an alternate, so HostRoot(A) is reused and reset, not allocated.3738 Then finishQueueingConcurrentUpdates drains the buffer. It appends the update into queue.pending as a circular list, and markUpdateLaneFromFiberToRoot walks from App up and ORs SyncLane into every ancestor's childLanes, on both twins.3940
root.pendingLanes = SyncLane
HostRoot.lanes = 0 HostRoot.childLanes = SyncLane
App.lanes = SyncLane App.childLanes = 0
App hook[0].queue.pending ─► {lane: SyncLane, action: c => c + 1, hasEagerState: true, eagerState: 1} ⟲13. beginWork(HostRoot): bail out, follow childLanes
HostRoot's memoizedProps === pendingProps (both null), and checkScheduledUpdateOrContext finds no SyncLane in its own lanes. So beginWork takes attemptEarlyBailoutIfNoScheduledUpdate, which pushes the root contexts and calls bailoutOnAlreadyFinishedWork.414243 childLanes intersects the render lanes, so instead of returning null it calls cloneChildFibers, which makes a WIP for App with createWorkInProgress(currentApp, currentApp.pendingProps), i.e. the old props object.4445 App has no alternate yet, so this allocates App's second fiber.
→ deep dive: beginWork & Reconciliation
14. beginWork(App): same props, but it has a lane, so it renders
App's oldProps === newProps holds (step 13 passed the old object). This time checkScheduledUpdateOrContext does find SyncLane in current.lanes, so beginWork sets didReceiveUpdate = false and falls through to updateFunctionComponent.4146 renderWithHooks sees current.memoizedState !== null and installs HooksDispatcherOnUpdate. It nulls the WIP's memoizedState and updateQueue, and calls App().4748
15. updateReducerImpl produces 1, and didReceiveUpdate flips
useState → updateState → updateReducer → updateReducerImpl.495051 First updateWorkInProgressHook clones hook #0 from the current fiber. The queue object is shared, not copied.52 Then:
queue.pendingis moved intobaseQueue(and ontocurrent.baseQueue, so a discarded render can't lose it).pending = null.- The single update's lane is in the render lanes, so it's applied.
hasEagerStateistrue, sonewState = eagerState = 1. Your updater isn't called again. !is(1, 0)→markWorkInProgressReceivedUpdate()setsdidReceiveUpdate = true.5354hook.memoizedState = 1,queue.lastRenderedState = 1.
useState returns [1, setCount]. The same setCount, because queue.dispatch never changes.
→ deep dive: Hooks on Update
16. Effects: deps changed, so both get HasEffect
useLayoutEffect and useEffect go through updateEffectImpl. areHookInputsEqual([1], [0]) is false, so each pushes an effect tagged HookHasEffect | … and sets the fiber flag (Update for layout, Passive for passive).55 Both effects are pushed onto the fresh updateQueue ring either way. Unchanged ones would just lack HasEffect.
17. App's output is reconciled: a new <div>, fiber reused
App() returns a brand-new tree of element objects. didReceiveUpdate is true, so there's no late bailout. reconcileChildren → reconcileChildFibers (tracking on, since App has a current) → reconcileSingleElement: same key (null), same type ('div'), so it calls useFiber(currentDiv, element.props).565758 The WIP div's pendingProps is the new props object.
18. beginWork(div): three children, three new props objects
div's oldProps !== newProps, so didReceiveUpdate = true and updateHostComponent runs. Its children are an array, so reconcileChildrenArray runs. In the first pass, updateSlot matches each position by key, and updateElement → useFiber reuses the Header, button and Footer fibers with the new elements' props.596061 No Placement (same order) and no ChildDeletion.
Header memoizedProps {title:"Counter"} ≠ pendingProps {title:"Counter"} ← different objects
button {onClick: arrow#1, children: 0} ≠ {onClick: arrow#2, children: 1}
Footer {} ≠ {}19. Without memoization the cascade continues: Header and Footer re-render
beginWork(Header): oldProps !== newProps (identity, not equality), so didReceiveUpdate = true and Header runs. Header has no hooks, so current.memoizedState === null and renderWithHooks actually picks the mount dispatcher. That's harmless.47 It returns a new <h1> element, and h1 gets a new props object {children: "Counter"}. The same happens to Footer → footer. button is a host fiber whose child is the number 1: shouldSetTextContent is true, so there's no child fiber, and updateHostComponent returns null.62
This is the default React behaviour: a re-render re-renders the whole subtree below the component that updated. The childLanes bailout only spared the ancestors (step 13). React.memo on Header/Footer would stop the cascade with a shallowEqual, and the compiler stops it with identity (below).
20. completeWork: mark, don't diff
On the way back up, completeWork for each HostComponent with a current and a stateNode calls the complete-phase updateHostComponent. It is just if (oldProps === newProps) return; markUpdate(workInProgress).6364 React 19 has no update payload. There's no prepareUpdate and no diffProperties in the render phase. The diff is deferred to the commit. So h1, button, footer and div all get Update, even though only one of them will change. bubbleProperties ORs it all up.65
App flags: Update | Passive (effects with HasEffect)
div flags: Update (children array is a new object)
h1 flags: Update (new {children:"Counter"})
button flags: Update (children 0 → 1, onClick arrow#1 → arrow#2)
footer flags: Update
HostRoot.subtreeFlags ⊇ Update | Passive→ deep dive: completeWork
21. Commit, mutation: commitUpdate → updateProperties
finishConcurrentRender → completeRootWhenReady → completeRoot → commitRoot.66676869 markRootFinished clears SyncLane, and the Normal-priority passive callback is posted as before.70 In flushMutationEffects, commitMutationEffectsOnFiber descends where subtreeFlags & MutationMask.7172 For each HostComponent with Update it calls commitHostUpdate → DOM commitUpdate, which runs updateProperties(dom, type, oldProps, newProps) and then updateFiberProps(dom, newProps).737475
updateProperties is the real diff: two loops over lastProps/nextProps, calling setProp only where nextProp !== lastProp.7677
updateFiberProps then points __reactProps$ at the new props, so the next click calls arrow#2.80 App's Update flag also runs layout-effect cleanups here. Your layout effect returned nothing, so there's nothing to call.72
22. The swap
root.current = finishedWork (line 4051).84 HostRoot(A) is current again. B becomes the buffer for the next render. Every fiber that was cloned in this render now has an alternate.
23. Layout effects
flushLayoutEffects → commitLayoutEffectOnFiber → App has Update → commitHookLayoutEffects runs document.title = "count: 1".858687 The DOM already shows 1, but nothing has painted.
24. flushSpawnedWork: passive effects run now, before paint
flushSpawnedWork calls requestPaint() and marks the passive phase as pending. Then:8889
if (includesSyncLane(pendingEffectsLanes) && (disableLegacyMode || root.tag !== LegacyRoot)) {
flushPendingEffects();
}The committed lanes include SyncLane, so flushPendingEffects → flushPassiveEffects → flushPassiveEffectsImpl runs all passive cleanups (none here, since the effect returned nothing), then mounts. console.log("committed", 1) runs before the browser paints.9091 The source comment gives the reason: the result of a discrete event should be "immediately observable". Later, flushSyncWorkOnAllRoots() would render any sync setState your effects made, still in this microtask.88 The Normal-priority callback that commitRoot posted runs in a later task, finds no pending passive phase, and does nothing.90
25. Pixels
The microtask finishes, the event task ends, and the browser runs style, layout and paint. The user sees 1. Everything from the click to the console log ran in one uninterrupted task.
With the React Compiler
Here is the compiled App again, verbatim from babel-plugin-react-compiler@1.0.0 (full output, including Header and Footer, in First Render Walkthrough). The comments are mine:
function App() {
const $ = _c(13);
const [count, setCount] = useState(0);
let t0;
let t1;
if ($[0] !== count) { // 1 !== 0 → recompute
t0 = () => { document.title = `count: ${count}`; };
t1 = [count];
$[0] = count; $[1] = t0; $[2] = t1;
} else {
t0 = $[1]; t1 = $[2];
}
useLayoutEffect(t0, t1);
let t2;
let t3;
if ($[3] !== count) { // recompute
t2 = () => { console.log("committed", count); };
t3 = [count];
$[3] = count; $[4] = t2; $[5] = t3;
} else {
t2 = $[4]; t3 = $[5];
}
useEffect(t2, t3);
let t4;
let t5;
if ($[6] === Symbol.for("react.memo_cache_sentinel")) { // false now → cache hit
t4 = <Header title="Counter" />;
t5 = () => setCount(_temp);
$[6] = t4; $[7] = t5;
} else {
t4 = $[6]; t5 = $[7]; // SAME element object, SAME handler as the mount
}
let t6;
if ($[8] !== count) { // recompute: new <button> element
t6 = <button onClick={t5}>{count}</button>;
$[8] = count; $[9] = t6;
} else {
t6 = $[9];
}
let t7;
if ($[10] === Symbol.for("react.memo_cache_sentinel")) { // cache hit
t7 = <Footer />;
$[10] = t7;
} else {
t7 = $[10]; // SAME element object
}
let t8;
if ($[11] !== t6) { // t6 changed → new <div>
t8 = <div>{t4}{t6}{t7}</div>;
$[11] = t6; $[12] = t8;
} else {
t8 = $[12];
}
return t8;
}
function _temp(c) { return c + 1; }Re-walking the 25 steps:
- Steps 1–5: unchanged, except handler identity. The
onClickstored in__reactProps$is the cachedt5, created once on mount. It is the same function on every render. - Steps 6–13: unchanged.
setCount(_temp)is the samedispatchSetState, the sameSyncLane, the same eager computation (_temp(0) = 1), the same buffer, microtask andprepareFreshStack, and the same HostRoot bailout. The compiler doesn't change who is scheduled. - Step 14: App still re-renders. It owns the lane. The first call is
_c(13)→useMemoCache(13).renderWithHooksnulled the WIP'supdateQueue, souseMemoCachereads the cache fromalternate.updateQueue.memoCacheand makes a copy-on-write clone,data.map(array => array.slice()), becauseenableNoCloningMemoCacheisfalse. It attaches the clone to a freshupdateQueueon the WIP.9293 Writes in this render go to the clone. If the render were thrown away, the committed cache would still match the screen. - Steps 15–16: unchanged. Same
updateReducerImplresult, samedidReceiveUpdate. The effect scopes recompute oncount, so both effects still getHasEffect. The compiler only avoids spurious effect re-runs, and this one is real. - Step 17: different element identities.
t4(<Header title="Counter"/>) andt7(<Footer/>) come from$[6]and$[10]. They are the very objects returned on mount.t6andt8are new. The div fiber is reused exactly as before. - Step 18: the key difference.
reconcileChildrenArray→updateElement→useFiber(currentHeader, t4.props).t4.propsis the object that becameHeader.memoizedPropson mount, so the WIP Header getspendingProps === current.memoizedProps. Same for Footer.6158 - Step 19: bailout instead of cascade.
beginWork(Header):oldProps === newProps,checkScheduledUpdateOrContextfalse, soattemptEarlyBailoutIfNoScheduledUpdate→bailoutOnAlreadyFinishedWork.Header.childLanesis0, so it returnsnull.414344Header()never runs, and theh1fiber isn't even cloned: the WIP Header'schildstill points at the currenth1. Footer gets the identical treatment.buttonis processed as before, becauset6is new. - Step 20: fewer
Updateflags.completeWork(Header)hits thedidBailoutbranch ofbubbleProperties(child is still the current child) and bubbles only static flags.65h1andfooteraren't visited, so they get noUpdate.div(newt8props) andbutton(newt6props) are flagged as before. - Step 21: same DOM write, less diffing.
updatePropertiesruns ondiv(no-op) andbutton. On the button,children: 0 → 1→setTextContent→nodeValue = "1".onClickis now identical (t5), so the loop skips it. Theh1andfooterno-op diffs don't happen at all. - Steps 22–25: unchanged. Same swap, same layout effect, same synchronous passive flush, same paint.
flowchart TD
R[HostRoot: bailout via childLanes] --> A["App: RENDERS (owns SyncLane)"]
A --> D["div: new props (t8) → reconcile"]
D --> H["Header: oldProps === newProps → BAILOUT"]
D --> BT["button: new props (t6) → Update"]
D --> F["Footer: oldProps === newProps → BAILOUT"]
H -.-> H1["h1: not visited"]
F -.-> FT["footer: not visited"]
classDef render fill:#f8d7da,stroke:#c0392b,color:#000;
classDef bail fill:#d4edda,stroke:#27ae60,color:#000;
classDef skip fill:#eeeeee,stroke:#999,color:#555,stroke-dasharray: 4 3;
class A,D,BT render
class R,H,F bail
class H1,FT skipStep by step comparison
Counted over the whole update: component functions called 3 → 1, beginWork calls 8 → 6, host fibers diffed at commit 4 → 2, DOM writes that change pixels 1 → 1. In this tiny app that's noise. Under a real Header with a hundred descendants, it's the difference between re-rendering a hundred fibers and touching one. See With vs Without Compiler for the same effect on a larger tree, and React Compiler for how the scopes are derived.
Summary
A click reaches React through the root listener createRoot installed, and dispatchDiscreteEvent sets the priority around your handler, so setCount gets SyncLane. The update is buffered, and once the handler returns a microtask renders and commits it synchronously, with no Scheduler task. The render follows childLanes down to App, re-runs it and, without the compiler, Header and Footer too; the diff happens at commit and the only real write is the button's text. With the compiler, cached elements make Header and Footer bail out, and the DOM writes stay identical.
Key takeaways
- Root listener to fiber: the bubble listener maps the target to the
buttonfiber via__reactFiber$and collectsonClickfrom the latest committed__reactProps$(Event System). SyncLanefrom the event:requestUpdateLanereadsDiscreteEventPriority, which isSyncLane(setState).- Eager state: the reducer runs early only when no lanes are pending, so "same value means no render" is best-effort, not a guarantee.
- Buffered, then linked:
enqueueConcurrentHookUpdateparks the update;finishQueueingConcurrentUpdateslinks it and setschildLaneson every ancestor inprepareFreshStack. - No Scheduler task: for
SyncLane,flushSyncWorkAcrossRoots_implrenders inside the microtask. - Bail to App, then render: the HostRoot bails out and clones its child;
Apphas the lane, soupdateReducerImplreuses the eager1and flipsdidReceiveUpdate(Hooks on Update). - The uncompiled cascade: new element objects mean new props objects, so
HeaderandFooterre-render on identity alone (beginWork). - Mark, don't diff:
completeWorkonly callsmarkUpdate;commitUpdate→updatePropertiesdoes the real diff and writes only the button's text. - Effects before paint: layout effects run after the swap, and because the commit includes
SyncLane,flushSpawnedWorkflushes passive effects before the browser paints. - With the compiler:
Appstill renders, but cached<Header/>and<Footer/>makeoldProps === newPropstrue, so their subtrees are skipped (With vs Without Compiler).
Footnotes
-
listenToAllSupportedEvents— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L432-L459 (opens in a new tab) ↩ -
createEventListenerWrapperWithPriority— packages/react-dom-bindings/src/events/ReactDOMEventListener.js#L94-L119 (opens in a new tab) ↩ -
getEventPriority— packages/react-dom-bindings/src/events/ReactDOMEventListener.js#L312-L416 (opens in a new tab) ↩ -
dispatchDiscreteEvent— packages/react-dom-bindings/src/events/ReactDOMEventListener.js#L121-L137 (opens in a new tab) ↩ -
dispatchEvent— packages/react-dom-bindings/src/events/ReactDOMEventListener.js#L157-L235 (opens in a new tab) ↩ -
findInstanceBlockingEvent— packages/react-dom-bindings/src/events/ReactDOMEventListener.js#L237-L242 (opens in a new tab) ↩ -
getClosestInstanceFromNode— packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L136-L223 (opens in a new tab) ↩ -
dispatchEventForPluginEventSystem— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L589-L703 (opens in a new tab) ↩ -
dispatchEventsForPlugins— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L325-L344 (opens in a new tab) ↩ -
SimpleEventPlugin.extractEvents— packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js#L57-L235 (opens in a new tab) ↩ -
accumulateSinglePhaseListeners— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L717-L829 (opens in a new tab) ↩ -
getListener— packages/react-dom-bindings/src/events/getListener.js#L51-L78 (opens in a new tab) ↩ -
processDispatchQueue— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L313-L323 (opens in a new tab) ↩ -
processDispatchQueueItemsInOrder— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L266-L311 (opens in a new tab) ↩ -
executeDispatch— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L252-L264 (opens in a new tab) ↩ -
mountState(bindsdispatchSetState) — packages/react-reconciler/src/ReactFiberHooks.js#L1948-L1960 (opens in a new tab) ↩ -
dispatchSetState— packages/react-reconciler/src/ReactFiberHooks.js#L3626-L3654 (opens in a new tab) ↩ -
requestUpdateLane— packages/react-reconciler/src/ReactFiberWorkLoop.js#L812-L868 (opens in a new tab) ↩ -
resolveUpdatePriority(DOM) — packages/react-dom-bindings/src/client/ReactDOMUpdatePriority.js#L35-L45 (opens in a new tab) ↩ -
DiscreteEventPriority = SyncLane— packages/react-reconciler/src/ReactEventPriorities.js#L24-L28 (opens in a new tab) ↩ -
dispatchSetStateInternal(eager state) — packages/react-reconciler/src/ReactFiberHooks.js#L3656-L3726 (opens in a new tab) ↩ -
bailoutHooks— packages/react-reconciler/src/ReactFiberHooks.js#L908-L927 (opens in a new tab) ↩ -
enqueueConcurrentHookUpdate— packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L115-L125 (opens in a new tab) ↩ -
enqueueUpdate(concurrent buffer) — packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L90-L113 (opens in a new tab) ↩ -
getRootForUpdatedFiber— packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L252-L276 (opens in a new tab) ↩ -
scheduleUpdateOnFiber— packages/react-reconciler/src/ReactFiberWorkLoop.js#L987-L1113 (opens in a new tab) ↩ -
markRootUpdated— packages/react-reconciler/src/ReactFiberLane.js#L825-L849 (opens in a new tab) ↩ -
ensureRootIsScheduled— packages/react-reconciler/src/ReactFiberRootScheduler.js#L116-L152 (opens in a new tab) ↩ -
ensureScheduleIsScheduled— packages/react-reconciler/src/ReactFiberRootScheduler.js#L154-L169 (opens in a new tab) ↩ -
scheduleImmediateRootScheduleTask— packages/react-reconciler/src/ReactFiberRootScheduler.js#L650-L696 (opens in a new tab) ↩ -
processRootScheduleInMicrotask— packages/react-reconciler/src/ReactFiberRootScheduler.js#L259-L348 (opens in a new tab) ↩ -
scheduleTaskForRootDuringMicrotask(sync branch L442-L455) — packages/react-reconciler/src/ReactFiberRootScheduler.js#L384-L509 (opens in a new tab) ↩ -
flushSyncWorkAcrossRoots_impl— packages/react-reconciler/src/ReactFiberRootScheduler.js#L185-L247 (opens in a new tab) ↩ -
performSyncWorkOnRoot— packages/react-reconciler/src/ReactFiberRootScheduler.js#L608-L622 (opens in a new tab) ↩ -
shouldTimeSliceinperformWorkOnRoot— packages/react-reconciler/src/ReactFiberWorkLoop.js#L1165-L1181 (opens in a new tab) ↩ -
renderRootSync— packages/react-reconciler/src/ReactFiberWorkLoop.js#L2623-L2768 (opens in a new tab) ↩ -
prepareFreshStack— packages/react-reconciler/src/ReactFiberWorkLoop.js#L2023-L2293 (opens in a new tab) ↩ -
createWorkInProgress— packages/react-reconciler/src/ReactFiber.js#L323-L437 (opens in a new tab) ↩ -
finishQueueingConcurrentUpdates— packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L50-L84 (opens in a new tab) ↩ -
markUpdateLaneFromFiberToRoot— packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L189-L250 (opens in a new tab) ↩ -
beginWork(bailout gate L4250-L4320) — packages/react-reconciler/src/ReactFiberBeginWork.js#L4226-L4512 (opens in a new tab) ↩ ↩2 ↩3 -
checkScheduledUpdateOrContext— packages/react-reconciler/src/ReactFiberBeginWork.js#L3939-L3956 (opens in a new tab) ↩ -
attemptEarlyBailoutIfNoScheduledUpdate— packages/react-reconciler/src/ReactFiberBeginWork.js#L3958-L4224 (opens in a new tab) ↩ ↩2 -
bailoutOnAlreadyFinishedWork— packages/react-reconciler/src/ReactFiberBeginWork.js#L3826-L3865 (opens in a new tab) ↩ ↩2 -
cloneChildFibers— packages/react-reconciler/src/ReactChildFiber.js#L2120-L2146 (opens in a new tab) ↩ -
updateFunctionComponent— packages/react-reconciler/src/ReactFiberBeginWork.js#L1453-L1562 (opens in a new tab) ↩ -
renderWithHooks(dispatcher choice at L562-L566) — packages/react-reconciler/src/ReactFiberHooks.js#L505-L634 (opens in a new tab) ↩ ↩2 -
HooksDispatcherOnUpdate— packages/react-reconciler/src/ReactFiberHooks.js#L3954-L3980 (opens in a new tab) ↩ -
updateState— packages/react-reconciler/src/ReactFiberHooks.js#L1962-L1966 (opens in a new tab) ↩ -
updateReducer— packages/react-reconciler/src/ReactFiberHooks.js#L1307-L1314 (opens in a new tab) ↩ -
updateReducerImpl— packages/react-reconciler/src/ReactFiberHooks.js#L1316-L1589 (opens in a new tab) ↩ -
updateWorkInProgressHook— packages/react-reconciler/src/ReactFiberHooks.js#L1004-L1073 (opens in a new tab) ↩ -
updateReducerImpl: eager state reuse and the!is(newState, hook.memoizedState)check — packages/react-reconciler/src/ReactFiberHooks.js#L1534-L1556 (opens in a new tab) ↩ -
markWorkInProgressReceivedUpdate— packages/react-reconciler/src/ReactFiberBeginWork.js#L3800-L3802 (opens in a new tab) ↩ -
updateEffectImpl— packages/react-reconciler/src/ReactFiberHooks.js#L2657-L2695 (opens in a new tab) ↩ -
reconcileChildren— packages/react-reconciler/src/ReactFiberBeginWork.js#L339-L370 (opens in a new tab) ↩ -
reconcileSingleElement— packages/react-reconciler/src/ReactChildFiber.js#L1703-L1806 (opens in a new tab) ↩ -
useFiber— packages/react-reconciler/src/ReactChildFiber.js#L502-L509 (opens in a new tab) ↩ ↩2 -
updateHostComponent(begin phase) — packages/react-reconciler/src/ReactFiberBeginWork.js#L1966-L2033 (opens in a new tab) ↩ -
reconcileChildrenArray— packages/react-reconciler/src/ReactChildFiber.js#L1177-L1369 (opens in a new tab) ↩ -
updateElement— packages/react-reconciler/src/ReactChildFiber.js#L580-L636 (opens in a new tab) ↩ ↩2 -
shouldSetTextContent— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L744-L755 (opens in a new tab) ↩ -
updateHostComponent(complete phase) — packages/react-reconciler/src/ReactFiberCompleteWork.js#L463-L550 (opens in a new tab) ↩ -
markUpdate— packages/react-reconciler/src/ReactFiberCompleteWork.js#L201-L203 (opens in a new tab) ↩ -
bubbleProperties— packages/react-reconciler/src/ReactFiberCompleteWork.js#L793-L914 (opens in a new tab) ↩ ↩2 -
finishConcurrentRender— packages/react-reconciler/src/ReactFiberWorkLoop.js#L1420-L1576 (opens in a new tab) ↩ -
completeRootWhenReady— packages/react-reconciler/src/ReactFiberWorkLoop.js#L1578-L1701 (opens in a new tab) ↩ -
completeRoot— packages/react-reconciler/src/ReactFiberWorkLoop.js#L3511-L3723 (opens in a new tab) ↩ -
commitRoot— packages/react-reconciler/src/ReactFiberWorkLoop.js#L3725-L3922 (opens in a new tab) ↩ -
commitRootposting the passive-effects callback — packages/react-reconciler/src/ReactFiberWorkLoop.js#L3771-L3820 (opens in a new tab) ↩ -
flushMutationEffects— packages/react-reconciler/src/ReactFiberWorkLoop.js#L4009-L4053 (opens in a new tab) ↩ -
commitMutationEffectsOnFiber— packages/react-reconciler/src/ReactFiberCommitWork.js#L2072-L2843 (opens in a new tab) ↩ ↩2 -
commitMutationEffectsOnFiber,HostComponentcase — packages/react-reconciler/src/ReactFiberCommitWork.js#L2274-L2344 (opens in a new tab) ↩ -
commitHostUpdate— packages/react-reconciler/src/ReactFiberCommitHostEffects.js#L114-L143 (opens in a new tab) ↩ -
commitUpdate(DOM) — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L999-L1012 (opens in a new tab) ↩ -
updateProperties— packages/react-dom-bindings/src/client/ReactDOMComponent.js#L1518-L2023 (opens in a new tab) ↩ -
updateProperties, generic remove/set loops — packages/react-dom-bindings/src/client/ReactDOMComponent.js#L2002-L2023 (opens in a new tab) ↩ -
setTextContent(reuses a sole text node'snodeValue) — packages/react-dom-bindings/src/client/setTextContent.js#L21-L35 (opens in a new tab) ↩ -
setProp,case 'onClick'→trapClickOnNonInteractiveElement— packages/react-dom-bindings/src/client/ReactDOMComponent.js#L618-L627 (opens in a new tab) ↩ -
updateFiberProps— packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L295-L301 (opens in a new tab) ↩ -
commitTextUpdate(DOM) — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L1018-L1024 (opens in a new tab) ↩ -
commitHostTextUpdate— packages/react-reconciler/src/ReactFiberCommitHostEffects.js#L145-L167 (opens in a new tab) ↩ -
updateHostText(complete phase) — packages/react-reconciler/src/ReactFiberCompleteWork.js#L674-L703 (opens in a new tab) ↩ -
root.current = finishedWork— packages/react-reconciler/src/ReactFiberWorkLoop.js#L4047-L4051 (opens in a new tab) ↩ -
flushLayoutEffects— packages/react-reconciler/src/ReactFiberWorkLoop.js#L4055-L4152 (opens in a new tab) ↩ -
commitLayoutEffectOnFiber— packages/react-reconciler/src/ReactFiberCommitWork.js#L600-L934 (opens in a new tab) ↩ -
commitHookLayoutEffects— packages/react-reconciler/src/ReactFiberCommitEffects.js#L97-L112 (opens in a new tab) ↩ -
flushSpawnedWork(requestPaint()L4189,flushSyncWorkOnAllRoots()L4401) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4154-L4437 (opens in a new tab) ↩ ↩2 -
Sync-lane passive flush in
flushSpawnedWork— packages/react-reconciler/src/ReactFiberWorkLoop.js#L4316-L4329 (opens in a new tab) ↩ -
flushPassiveEffects(returns early unless the passive phase is pending) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4691-L4726 (opens in a new tab) ↩ ↩2 -
flushPassiveEffectsImpl— packages/react-reconciler/src/ReactFiberWorkLoop.js#L4728-L4875 (opens in a new tab) ↩ -
useMemoCache— packages/react-reconciler/src/ReactFiberHooks.js#L1181-L1263 (opens in a new tab) ↩ -
enableNoCloningMemoCache = false— packages/shared/ReactFeatureFlags.js#L128 (opens in a new tab) ↩