A line-by-line execution trace of one click through React 19.2.5, naming the real functions in
react-dom-client.development.jsat each step. This note is about invocation order. The concepts are explained in Render and Commit, Reconciliation, React Fiber, The Work Loop, Commit Phase and Effects, Scheduler, Lanes and Batching and Hooks Under the Hood. Part of React Internals.
The app
function App() {
return (
<main>
<Header />
<Counter />
</main>
)
}
function Header() {
return <h1>Clicks</h1>
}
function Counter() {
const [count, setCount] = useState(0)
const buttonRef = useRef<HTMLButtonElement>(null)
useLayoutEffect(() => {
buttonRef.current!.style.width = `${40 + count * 10}px`
}, [count])
useEffect(() => {
document.title = `Clicks: ${count}`
}, [count])
return (
<button ref={buttonRef} onClick={() => setCount(count + 1)}>
{count}
</button>
)
}
The fiber tree after mount (current), with the hook list on Counter:
HostRoot
App
main
Header
h1 → "Clicks"
Counter
button → "0"
Counter fiber.memoizedState#1useState
→0.next#2useRef
→{ current: <button> }.next#3useLayoutEffect
→deps [0].next#4useEffect
→deps [0].nextnull
current fiber tree after mount, with Counter’s hook list.Render trace: clicking the button once
fiber tree
HostRootflag
Appflag
mainflag
Header
h1
Countercount: 0flag
button"0"flag
React
lane: –Counter queue: emptymicrotask: none
screen
<button style="width: 40px">0</button>
document.title = "Clicks: 0"
document.title = "Clicks: 0"
- event
dispatchDiscreteEvent("click") → onClickOne listener on the root container catches the click, finds the button’s fiber and calls youronClick.stack: dispatchDiscreteEvent › dispatchEvent › onClick - schedule
dispatchSetState → requestUpdateLane() = SyncLaneA discrete event, soSyncLane. The eager check: 1 ≠ 0, so a render is needed.stack: dispatchDiscreteEvent › dispatchEvent › onClick › dispatchSetState › requestUpdateLane - schedule
ensureRootIsScheduled → queueMicrotaskThe handler returns. Nothing has rendered yet.stack: dispatchDiscreteEvent › dispatchEvent › onClick › dispatchSetState › scheduleUpdateOnFiber › ensureRootIsScheduled - render phase
performSyncWorkOnRoot → prepareFreshStack → markUpdateLaneFromFiberToRootThe microtask starts the render. First the queued update is linked into the hook, thenCounter.lanes |= Syncand every ancestor getschildLanes |= Sync: a trail back from the root.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › prepareFreshStack › finishQueueingConcurrentUpdates › markUpdateLaneFromFiberToRoot - render phase
workLoopSync · beginWork(HostRoot, App, main)Now the work loop. HostRoot, App and main only havechildLanes→ bail out and continue down.App()is not called.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(main) › bailoutOnAlreadyFinishedWork - render phase
beginWork(Header) → nullHeaderhas no work at all (childLanes === 0): the whole subtree is skipped.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Header) › bailoutOnAlreadyFinishedWork - render phase
Counter() → updateReducer → count = 1Counter has its own lane → it renders. Both effects’ deps changed ([0]→[1]), so they are flagged.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter() › updateReducer - render phase
useFiber(button) · completeWork(button) → markUpdateThe button fiber is reused with new props and flagged Update. ThencompleteWorkclimbs back up, bubbling flags.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(button) - commit phase
commitMutationEffects → commitUpdate(button)Commit, mutation: only the flagged path is visited. The text changes from 0 to 1. Thenroot.current = finishedWork.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitUpdate - commit phase
commitLayoutEffects → useLayoutEffectLayout effect:buttonRef.current.style.width = '50px'. Still before paint.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushLayoutEffects › commitLayoutEffectOnFiber › commitHookEffectListMount - commit phase
flushPendingEffects() (SyncLane) → useEffectFor a discrete update, passive effects are flushed synchronously at the end of the commit:document.titleupdates.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushPassiveEffects › commitHookEffectListMount - browser
microtask ends → style · layout · paintFinally the browser paints: the user sees 1 in a 50px button. OnlyCounter()ran, and there were 2 DOM writes.stack:
current stepcomponent runningupdated / movedbailed outskippedcompletedok
Call stack (outermost first; the last line is running now)
0 / 0
Browser
React root listener
Counter handler
Microtask
Work loop
Commit
1
native
click2
dispatchDiscreteEvent → onClick3
setCount(1) · SyncLane · queueMicrotask4
handler returns, nothing rendered yet
5
performSyncWorkOnRoot → renderRootSync6
HostRoot, App, main bail out · Header skipped
7
Counter() runs → count = 18
commitRoot9
mutation: "0" → "1", then
root.current = finishedWork10
layout:
useLayoutEffect sets width 50px11
passive (SyncLane):
document.title = "Clicks: 1"12
microtask ends → paint
Phase 1: the event
- The browser dispatches a native
click. React doesn’t attach listeners to individual nodes. AtcreateRoottime,listenToAllSupportedEventsregistered one listener per event type on the root container. - The root listener for a discrete event is
dispatchDiscreteEvent. It setsReactDOMSharedInternals.p = DiscreteEventPriority(and clears any ambient transition), then callsdispatchEvent. dispatchEvent→dispatchEventForPluginEventSystemfinds the fiber for the target DOM node (React stores a pointer to it on the node), walksreturnpointers up to the root collectingonClickprops, and creates aSyntheticEvent.- Your handler runs:
setCount(count + 1), which issetCount(1).
Phase 2: scheduling the update
dispatchSetState(CounterFiber, queue, 1)→requestUpdateLane→ no transition, soresolveUpdatePriority()returns the priority from step 2:SyncLane.dispatchSetStateInternal:Counter’s fiber has no pending lanes, so the eager path runs.basicStateReducer(0, 1)→1.Object.is(1, 0)is false, so it keeps going (the update keepseagerState: 1).enqueueConcurrentHookUpdatestashes the update inconcurrentQueuesandgetRootForUpdatedFiberwalks up to theFiberRoot.scheduleUpdateOnFiber(root, fiber, SyncLane)→markRootUpdatedaddsSyncLanetoroot.pendingLanes→ensureRootIsScheduled(root)→scheduleImmediateRootScheduleTask→queueMicrotask.- The handler returns, and
dispatchDiscreteEventrestores the previous priority. The browser event is over. Nothing has rendered yet. Any othersetStatein this handler would have joined the same batch.
Phase 3: render (synchronous, because the lane is SyncLane)
- The microtask runs
processRootScheduleInMicrotask. It sees a sync lane and callsflushSyncWorkAcrossRoots_impl→performSyncWorkOnRoot→performWorkOnRoot(root, SyncLane, true). (lanes & 127) !== 0, so this is a blocking lane →renderRootSync.prepareFreshStack(root, SyncLane):workInProgress = createWorkInProgress(root.current, null), a WIP HostRoot (the first time, a new fiber that becomes the alternate). ThenfinishQueueingConcurrentUpdateslinks the stashed update intoqueue.pendingand runsmarkUpdateLaneFromFiberToRoot:Counter.lanes |= SyncLane, andmain,AppandHostRootgetchildLanes |= SyncLane.workLoopSync:while (workInProgress !== null) performUnitOfWork(workInProgress).- HostRoot
beginWork: no own update, butchildLanesincludes Sync →bailoutOnAlreadyFinishedWorkclonesApp→ next = App. - App
beginWork:current.memoizedProps === workInProgress.pendingProps(the same cloned props object),checkScheduledUpdateOrContext→ false →attemptEarlyBailoutIfNoScheduledUpdate→bailoutOnAlreadyFinishedWork:childLaneshas Sync → clonemain→ next = main.App()is not called. - main
beginWork: same → bail out, cloneHeaderandCounter→ next = Header. - Header
beginWork: same props object, no lanes,childLanes === 0→bailoutOnAlreadyFinishedWorkreturnsnull. The whole Header subtree is skipped.completeUnitOfWork(Header): nothing to do → sibling → next = Counter. - Counter
beginWork:Counter.lanes & SyncLane→ must render →updateFunctionComponent→renderWithHooksinstallsHooksDispatcherOnUpdateand callsCounter(props):useState→updateReducer:baseQueue = [u1], u1 has eager state1→count = 1, which differs from 0 →didReceiveUpdate = true.useRef→ returns the same{ current: <button> }object.useLayoutEffect→updateEffectImpl: deps[1]vs[0]→Object.is(1, 0)false → fiber gets theUpdateflag, effect pushed withHasEffect | Layout.useEffect→ deps changed → fiber gets thePassiveflag, effect pushed withHasEffect | Passive.- Returns
<button ref={buttonRef} onClick={newFn}>{1}</button>.
reconcileChildren→reconcileChildFibersImpl(its single-element branch,reconcileSingleElementin the unminified source): the old child is abuttonwith keynull, and the new element is abuttonwith keynull→ same type →useFiber(oldButton, newProps). Next = button.- button
beginWork(the HostComponent case,updateHostComponentin the unminified source, whereshouldSetTextContentis checked): its children is the single text value1, which React handles as direct text content (a “single text child” optimization), so there’s no child fiber to descend into. It returnsnull. completeUnitOfWork(button)→completeWork:memoizedProps !== newProps→markUpdate(flagUpdate). The ref object is the same, so there’s noRefflag. Bubble:Counter.subtreeFlags |= Update.- Climb:
completeWork(Counter),completeWork(main),completeWork(App),completeWork(HostRoot), each bubblingsubtreeFlags.workInProgressbecomesnull, and the render is done. The only component that ran isCounter.
Phase 4: commit (synchronous)
commitRoot: firstflushPendingEffects()(none pending from earlier).flushSpawnedWorkwill need to handle passive effects later, becausesubtreeFlagscontainsPassive. For a non-sync lane that would meanscheduleCallback(NormalPriority, flushPassiveEffects). ForSyncLane, React flushes them at the end of the commit (step 29).- Before mutation
commitBeforeMutationEffects: nothing relevant (no classgetSnapshotBeforeUpdate). - Mutation
flushMutationEffects→commitMutationEffectsOnFiberwalks down only wheresubtreeFlagsis set (HostRoot → App → main → Counter → button, andHeaderis skipped):buttonhasUpdate→commitUpdate(dom, type, oldProps, newProps)→ text content"0"→"1", and the storedonClickprop is replaced.CounterhasUpdate→ run the layout effect cleanup from the previous commit (there was none, since the effect returned nothing).- Then
root.current = finishedWork. The new tree is now current.
- Layout
flushLayoutEffects→commitLayoutEffectOnFiber, child before parent: the button has no ref change. ForCounter, runuseLayoutEffectsetup →button.style.width = '50px'. This is still before paint. - The browser hasn’t painted yet, because we’re still inside the microtask.
flushSpawnedWork:pendingEffectsLanes & 3→ SyncLane → runflushPendingEffects()now →flushPassiveEffects:- unmount pass: no previous
useEffectcleanup - mount pass:
document.title = 'Clicks: 1'
- unmount pass: no previous
- Microtask ends → the browser runs style, layout and paint. The user
sees
1in a 50px-wide button, and the tab title reads “Clicks: 1”.
Summary of who ran
| Function | Ran? | Why |
|---|---|---|
App() |
no | bailout: same props, no update, only childLanes |
Header() |
no | bailout with childLanes === 0: subtree skipped |
Counter() |
yes | its own hook had a SyncLane update |
| DOM writes | 2 | button text + style.width (from the layout effect) |
Contrast: the same click inside startTransition
onClick={() => startTransition(() => setCount(count + 1))}
- Steps 1–4 are the same, but inside the callback
ReactSharedInternals.Tis a transition object. - Step 5:
requestUpdateLaneseesT !== null→requestTransitionLane()→TransitionLane1. - Step 10: the microtask finds no sync lanes, so
scheduleTaskForRootDuringMicrotasksets an expiration time for the lane (+5000ms) and callsscheduleCallback(NormalPriority, performWorkOnRootViaSchedulerTask). The microtask ends without rendering. The browser can paint and handle input. - In a later macrotask (posted through
MessageChannel), the Scheduler runs the task →performWorkOnRoot(root, TransitionLane1, false)→(lanes & 127) === 0→renderRootConcurrent→workLoopConcurrentByScheduler, which checksshouldYield()after every fiber. - Here the tree is tiny and finishes in one 5ms slice. For a large tree, the
loop would exit mid-tree, return a continuation, and resume later. If a
SyncLaneupdate arrived in between,prepareFreshStackwould discard this WIP and restart (Scheduler, Lanes and Batching). - The commit is the same as steps 23–27, except step 29. The lanes aren’t sync, so passive effects are scheduled as a separate Normal-priority task and run after the browser paints.
Contrast: setCount(count) with the same value
- Steps 1–5 are the same.
- Step 6: the eager path computes
basicStateReducer(0, 0)→0→Object.is(0, 0)→ true → the update is enqueued with no lane anddispatchSetStateInternalreturns without callingscheduleUpdateOnFiber. No microtask, no render, no commit.
Sources
- React 19.2.5 source:
dispatchDiscreteEvent,dispatchEventForPluginEventSystem,dispatchSetState,dispatchSetStateInternal,requestUpdateLane,enqueueConcurrentHookUpdate,scheduleUpdateOnFiber,ensureRootIsScheduled,processRootScheduleInMicrotask,performWorkOnRoot,renderRootSync,renderRootConcurrent,prepareFreshStack,finishQueueingConcurrentUpdates,workLoopSync,beginWork,bailoutOnAlreadyFinishedWork,renderWithHooks,completeWork,commitRoot,flushMutationEffects,flushLayoutEffects,flushSpawnedWork,flushPassiveEffects - React source:
packages/react-reconciler/src
Primary source: github.com/facebook/react/tree/main/packages/react-reconciler/src