Good to knowReact Internals · 10

A State Update, End to End

One click traced through ~30 real React functions, from the native event to paint and effects.

This section:trigger→schedule→render→commit→effects
On this page · 4 sections
  1. The app
  2. Render trace: clicking the button once
  3. Contrast: the same click inside startTransition
  4. Contrast: setCount(count) with the same value

A line-by-line execution trace of one click through React 19.2.5, naming the real functions in react-dom-client.development.js at 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
→
#1useState0.next
→
#2useRef{ current: <button> }.next
→
#3useLayoutEffectdeps [0].next
→
#4useEffectdeps [0].next
→
null
The 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"
  1. eventdispatchDiscreteEvent("click") → onClickOne listener on the root container catches the click, finds the button’s fiber and calls your onClick.stack: dispatchDiscreteEvent › dispatchEvent › onClick
  2. scheduledispatchSetState → requestUpdateLane() = SyncLaneA discrete event, so SyncLane. The eager check: 1 ≠ 0, so a render is needed.stack: dispatchDiscreteEvent › dispatchEvent › onClick › dispatchSetState › requestUpdateLane
  3. scheduleensureRootIsScheduled → queueMicrotaskThe handler returns. Nothing has rendered yet.stack: dispatchDiscreteEvent › dispatchEvent › onClick › dispatchSetState › scheduleUpdateOnFiber › ensureRootIsScheduled
  4. render phaseperformSyncWorkOnRoot → prepareFreshStack → markUpdateLaneFromFiberToRootThe microtask starts the render. First the queued update is linked into the hook, then Counter.lanes |= Sync and every ancestor gets childLanes |= Sync: a trail back from the root.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › prepareFreshStack › finishQueueingConcurrentUpdates › markUpdateLaneFromFiberToRoot
  5. render phaseworkLoopSync · beginWork(HostRoot, App, main)Now the work loop. HostRoot, App and main only have childLanes → bail out and continue down. App() is not called.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(main) › bailoutOnAlreadyFinishedWork
  6. render phasebeginWork(Header) → nullHeader has no work at all (childLanes === 0): the whole subtree is skipped.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Header) › bailoutOnAlreadyFinishedWork
  7. render phaseCounter() → 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
  8. render phaseuseFiber(button) · completeWork(button) → markUpdateThe button fiber is reused with new props and flagged Update. Then completeWork climbs back up, bubbling flags.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(button)
  9. commit phasecommitMutationEffects → commitUpdate(button)Commit, mutation: only the flagged path is visited. The text changes from 0 to 1. Then root.current = finishedWork.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitUpdate
  10. commit phasecommitLayoutEffects → useLayoutEffectLayout effect: buttonRef.current.style.width = '50px'. Still before paint.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushLayoutEffects › commitLayoutEffectOnFiber › commitHookEffectListMount
  11. commit phaseflushPendingEffects() (SyncLane) → useEffectFor a discrete update, passive effects are flushed synchronously at the end of the commit: document.title updates.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushPassiveEffects › commitHookEffectListMount
  12. browsermicrotask ends → style · layout · paintFinally the browser paints: the user sees 1 in a 50px button. Only Counter() ran, and there were 2 DOM writes.stack:
current stepcomponent runningupdated / movedbailed outskippedcompletedok

0 / 0
One click, end to end: event → schedule → render → commit → paint.
Browser
React root listener
Counter handler
Microtask
Work loop
Commit
1
native click
2
dispatchDiscreteEvent → onClick
3
setCount(1) · SyncLane · queueMicrotask
4
handler returns, nothing rendered yet
5
performSyncWorkOnRoot → renderRootSync
6
HostRoot, App, main bail out · Header skipped
7
Counter() runs → count = 1
8
commitRoot
9
mutation: "0" → "1", then root.current = finishedWork
10
layout: useLayoutEffect sets width 50px
11
passive (SyncLane): document.title = "Clicks: 1"
12
microtask ends → paint
One click, end to end. Steps 1–4 are the event, 5–7 render, 8–11 commit, 12 paint.

Phase 1: the event

  1. The browser dispatches a native click. React doesn’t attach listeners to individual nodes. At createRoot time, listenToAllSupportedEvents registered one listener per event type on the root container.
  2. The root listener for a discrete event is dispatchDiscreteEvent. It sets ReactDOMSharedInternals.p = DiscreteEventPriority (and clears any ambient transition), then calls dispatchEvent.
  3. dispatchEvent → dispatchEventForPluginEventSystem finds the fiber for the target DOM node (React stores a pointer to it on the node), walks return pointers up to the root collecting onClick props, and creates a SyntheticEvent.
  4. Your handler runs: setCount(count + 1), which is setCount(1).

Phase 2: scheduling the update

  1. dispatchSetState(CounterFiber, queue, 1) → requestUpdateLane → no transition, so resolveUpdatePriority() returns the priority from step 2: SyncLane.
  2. 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 keeps eagerState: 1).
  3. enqueueConcurrentHookUpdate stashes the update in concurrentQueues and getRootForUpdatedFiber walks up to the FiberRoot.
  4. scheduleUpdateOnFiber(root, fiber, SyncLane) → markRootUpdated adds SyncLane to root.pendingLanes → ensureRootIsScheduled(root) → scheduleImmediateRootScheduleTask → queueMicrotask.
  5. The handler returns, and dispatchDiscreteEvent restores the previous priority. The browser event is over. Nothing has rendered yet. Any other setState in this handler would have joined the same batch.

Phase 3: render (synchronous, because the lane is SyncLane)

  1. The microtask runs processRootScheduleInMicrotask. It sees a sync lane and calls flushSyncWorkAcrossRoots_impl → performSyncWorkOnRoot → performWorkOnRoot(root, SyncLane, true).
  2. (lanes & 127) !== 0, so this is a blocking lane → renderRootSync.
  3. prepareFreshStack(root, SyncLane): workInProgress = createWorkInProgress(root.current, null), a WIP HostRoot (the first time, a new fiber that becomes the alternate). Then finishQueueingConcurrentUpdates links the stashed update into queue.pending and runs markUpdateLaneFromFiberToRoot: Counter.lanes |= SyncLane, and main, App and HostRoot get childLanes |= SyncLane.
  4. workLoopSync: while (workInProgress !== null) performUnitOfWork(workInProgress).
  5. HostRoot beginWork: no own update, but childLanes includes Sync → bailoutOnAlreadyFinishedWork clones App → next = App.
  6. App beginWork: current.memoizedProps === workInProgress.pendingProps (the same cloned props object), checkScheduledUpdateOrContext → false → attemptEarlyBailoutIfNoScheduledUpdate → bailoutOnAlreadyFinishedWork: childLanes has Sync → clone main → next = main. App() is not called.
  7. main beginWork: same → bail out, clone Header and Counter → next = Header.
  8. Header beginWork: same props object, no lanes, childLanes === 0 → bailoutOnAlreadyFinishedWork returns null. The whole Header subtree is skipped. completeUnitOfWork(Header): nothing to do → sibling → next = Counter.
  9. Counter beginWork: Counter.lanes & SyncLane → must render → updateFunctionComponent → renderWithHooks installs HooksDispatcherOnUpdate and calls Counter(props):
    • useState → updateReducer: baseQueue = [u1], u1 has eager state 1 → 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 the Update flag, effect pushed with HasEffect | Layout.
    • useEffect → deps changed → fiber gets the Passive flag, effect pushed with HasEffect | Passive.
    • Returns <button ref={buttonRef} onClick={newFn}>{1}</button>.
  10. reconcileChildren → reconcileChildFibersImpl (its single-element branch, reconcileSingleElement in the unminified source): the old child is a button with key null, and the new element is a button with key null → same type → useFiber(oldButton, newProps). Next = button.
  11. button beginWork (the HostComponent case, updateHostComponent in the unminified source, where shouldSetTextContent is checked): its children is the single text value 1, which React handles as direct text content (a “single text child” optimization), so there’s no child fiber to descend into. It returns null.
  12. completeUnitOfWork(button) → completeWork: memoizedProps !== newProps → markUpdate (flag Update). The ref object is the same, so there’s no Ref flag. Bubble: Counter.subtreeFlags |= Update.
  13. Climb: completeWork(Counter), completeWork(main), completeWork(App), completeWork(HostRoot), each bubbling subtreeFlags. workInProgress becomes null, and the render is done. The only component that ran is Counter.

Phase 4: commit (synchronous)

  1. commitRoot: first flushPendingEffects() (none pending from earlier).
  2. flushSpawnedWork will need to handle passive effects later, because subtreeFlags contains Passive. For a non-sync lane that would mean scheduleCallback(NormalPriority, flushPassiveEffects). For SyncLane, React flushes them at the end of the commit (step 29).
  3. Before mutation commitBeforeMutationEffects: nothing relevant (no class getSnapshotBeforeUpdate).
  4. Mutation flushMutationEffects → commitMutationEffectsOnFiber walks down only where subtreeFlags is set (HostRoot → App → main → Counter → button, and Header is skipped):
    • button has Update → commitUpdate(dom, type, oldProps, newProps) → text content "0" → "1", and the stored onClick prop is replaced.
    • Counter has Update → 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.
  5. Layout flushLayoutEffects → commitLayoutEffectOnFiber, child before parent: the button has no ref change. For Counter, run useLayoutEffect setup → button.style.width = '50px'. This is still before paint.
  6. The browser hasn’t painted yet, because we’re still inside the microtask.
  7. flushSpawnedWork: pendingEffectsLanes & 3 → SyncLane → run flushPendingEffects() now → flushPassiveEffects:
    • unmount pass: no previous useEffect cleanup
    • mount pass: document.title = 'Clicks: 1'
  8. Microtask ends → the browser runs style, layout and paint. The user sees 1 in 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))}
  1. Steps 1–4 are the same, but inside the callback ReactSharedInternals.T is a transition object.
  2. Step 5: requestUpdateLane sees T !== null → requestTransitionLane() → TransitionLane1.
  3. Step 10: the microtask finds no sync lanes, so scheduleTaskForRootDuringMicrotask sets an expiration time for the lane (+5000ms) and calls scheduleCallback(NormalPriority, performWorkOnRootViaSchedulerTask). The microtask ends without rendering. The browser can paint and handle input.
  4. In a later macrotask (posted through MessageChannel), the Scheduler runs the task → performWorkOnRoot(root, TransitionLane1, false) → (lanes & 127) === 0 → renderRootConcurrent → workLoopConcurrentByScheduler, which checks shouldYield() after every fiber.
  5. 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 SyncLane update arrived in between, prepareFreshStack would discard this WIP and restart (Scheduler, Lanes and Batching).
  6. 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

  1. Steps 1–5 are the same.
  2. Step 6: the eager path computes basicStateReducer(0, 0) → 0 → Object.is(0, 0) → true → the update is enqueued with no lane and dispatchSetStateInternal returns without calling scheduleUpdateOnFiber. No microtask, no render, no commit.

Sources

Primary source: github.com/facebook/react/tree/main/packages/react-reconciler/src