Walkthrough: First Render End to End

createRoot(el).render(<App/>) renders nothing synchronously. It builds the root objects, attaches the event listeners, queues one DefaultLane update and books a microtask. The…

Published
Reading time
18 min read
React InternalsPart 20 · 17 of 18

This page is the narrative. Every step links to the deep-dive page that explains it properly. The companion story, a click that updates the counter, is setState Walkthrough.

The running example

The same app is used on both walkthrough pages. I added one layout effect and one passive effect so the commit phase has something to run:

JSX
import { useState, useEffect, useLayoutEffect } from "react";
import { createRoot } from "react-dom/client";

function Header({ title }) {
  return <h1>{title}</h1>;
}

function Footer() {
  return <footer>made with React</footer>;
}

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

createRoot(document.getElementById("root")).render(<App />);

The fiber tree this produces has eight fibers: HostRoot → App → div → [Header → h1, button, Footer → footer]. There are no HostText fibers. <h1>{title}</h1>, <button>{count}</button> and <footer>made with React</footer> each have a single string or number child, and shouldSetTextContent says those are written as textContent rather than getting their own fiber.1 That detail comes back in the update walkthrough.

The whole flow

Call stack at a glance

Text
── synchronous, in your module ───────────────────────────────────────────
jsx(App, {})                                   → ReactElement {type: App, props: {}}
createRoot(container)
  createContainer → createFiberRoot            → FiberRootNode + HostRoot fiber, initializeUpdateQueue
  markContainerAsRoot(root.current, container)
  listenToAllSupportedEvents(container)
root.render(<App/>)  (ReactDOMRoot.prototype.render)
  updateContainer
    requestUpdateLane(current)                 → DefaultLane
    updateContainerImpl
      createUpdate(lane); update.payload = {element}
      enqueueUpdate → enqueueConcurrentClassUpdate  (buffered in concurrentQueues)
      scheduleUpdateOnFiber
        markRootUpdated                        (root.pendingLanes |= DefaultLane)
        ensureRootIsScheduled → ensureScheduleIsScheduled
          scheduleImmediateRootScheduleTask    → queueMicrotask(...)
── microtask ──────────────────────────────────────────────────────────────
processRootScheduleInMicrotask
  scheduleTaskForRootDuringMicrotask           → scheduleCallback(NormalPriority, performWorkOnRootViaSchedulerTask)
── macrotask: Scheduler (MessageChannel) ──────────────────────────────────
performWorkUntilDeadline → flushWork → workLoop
  performWorkOnRootViaSchedulerTask
    performWorkOnRoot(root, DefaultLane, forceSync=false)
      renderRootSync                           (shouldTimeSlice === false)
        prepareFreshStack → createWorkInProgress(root.current), finishQueueingConcurrentUpdates
        workLoopSync → performUnitOfWork × 8
          beginWork(HostRoot) → updateHostRoot → processUpdateQueue → reconcileChildFibers
          beginWork(App)      → updateFunctionComponent → renderWithHooks → App()
                                 mountState, mountLayoutEffect, mountEffect → mountChildFibers
          beginWork(div)      → updateHostComponent → reconcileChildrenArray
          …
          completeUnitOfWork → completeWork(h1 | button | footer | div)
                                 createInstance, appendAllChildren, finalizeInitialChildren, bubbleProperties
      finishConcurrentRender → completeRootWhenReady → completeRoot → commitRoot
        commitBeforeMutationEffects            (HostRoot Snapshot → clearContainer)
        flushMutationEffects → commitMutationEffects → … → commitPlacement
          insertOrAppendPlacementNodeIntoContainer → appendChildToContainer
          root.current = finishedWork
        flushLayoutEffects → commitLayoutEffects → commitHookLayoutEffects
        flushSpawnedWork → requestPaint()
── browser: style / layout / paint ───────────────────────────────────────
── later macrotask: Scheduler ─────────────────────────────────────────────
flushPassiveEffects → flushPassiveEffectsImpl → commitPassiveMountEffects → useEffect create()

The steps

1. Module load: JSX becomes plain objects

Before React does anything, your bundler has turned <App /> into jsx(App, {}) (automatic runtime). In production jsx is jsxProd, which reuses the config object literal as props and calls ReactElement to build {$$typeof: REACT_ELEMENT_TYPE, type: App, key: null, ref: null, props: {}}.23 Nothing else happens. An element is an inert description, and none of App's code has run. useState is also just a forwarding stub at this point: it reads ReactSharedInternals.H, which is null outside a render.4

→ deep dive: Bootstrap

2. createRoot: FiberRoot, HostRoot, and every listener

createRoot(container) validates the container and calls createContainer, which calls createFiberRoot.56 That builds two objects pointing at each other:789

Text
ReactDOMRoot { _internalRoot } ──► FiberRootNode
                                     containerInfo: div#root
                                     current ──────────────► HostRoot fiber (tag 3, ConcurrentMode)
                                     pendingLanes: 0           stateNode ──► FiberRootNode
                                     callbackNode: null        memoizedState: {element: null, isDehydrated: false, cache}
                                                               updateQueue: {baseState, shared: {pending: null}, …}
                                                               alternate: null, child: null

initializeUpdateQueue gives the HostRoot a class-style update queue.10 Then createRoot stamps the container with __reactContainer$<random> (markContainerAsRoot) and calls listenToAllSupportedEvents(container), which attaches one capture listener and (for bubbling events) one bubble listener per native event type.1112 All the event wiring the click in page 21 needs exists now, before any component has rendered.

→ deep dive: createRoot, Event System

3. root.render(<App/>) → updateContainer

ReactDOMRoot.prototype.render checks the root isn't unmounted and calls updateContainer(children, root, null, null).13 updateContainer reads container.current (the HostRoot fiber), picks a lane, and hands off to updateContainerImpl.14

→ deep dive: root.render

4. requestUpdateLane → DefaultLane

requestUpdateLane checks for a render-phase update (no), then for a transition (ReactSharedInternals.T is null), and finally returns eventPriorityToLane(resolveUpdatePriority()).15 The DOM's resolveUpdatePriority finds no priority set by a React event wrapper and, at top level in a module, window.event === undefined, so it returns DefaultEventPriority.16 Event priorities are lanes: DefaultEventPriority is defined as DefaultLane.17

Text
SyncLane     = 0b0000000000000000000000000000010
DefaultLane  = 0b0000000000000000000000000100000   ← this update

18

→ deep dive: Lanes & Scheduler

5. The update is created and buffered

updateContainerImpl creates Update {lane: DefaultLane, tag: UpdateState, payload: {element: <App/>}} and calls enqueueUpdate(rootFiber, update, lane).1920 That goes to enqueueConcurrentClassUpdate, which does not link the update into updateQueue.shared.pending yet.21 It pushes (fiber, queue, update, lane) onto the module-level concurrentQueues array and marks fiber.lanes |= DefaultLane right away.22 getRootForUpdatedFiber walks .return to the HostRoot, which for the root fiber is immediate, and returns the FiberRoot.23

Text
concurrentQueues = [HostRoot, hostRootQueue, update{payload:{element}}, DefaultLane]
HostRoot.lanes   = DefaultLane
HostRoot.updateQueue.shared.pending = null      ← still empty; linked in step 10

→ deep dive: root.render, setState (same buffer)

6. scheduleUpdateOnFiber marks the root

scheduleUpdateOnFiber(root, rootFiber, DefaultLane) calls markRootUpdated, which ORs the lane into root.pendingLanes and clears the suspended/pinged bookkeeping.2425 We're not inside a render, so it goes on to ensureRootIsScheduled(root). After this step, root.pendingLanes === DefaultLane is the only record that there is work to do.

7. ensureRootIsScheduled books one microtask

ensureRootIsScheduled appends the root to the scheduler's linked list of roots, sets mightHavePendingSyncWork, and calls ensureScheduleIsScheduled.2627 That queues at most one microtask per tick via scheduleImmediateRootScheduleTask, and in the DOM host config scheduleMicrotask is queueMicrotask.2829 root.render now returns. Your script carries on. If it called root.render again, the second update would join the same queue and the same microtask.

8. Microtask: pick lanes, book a Scheduler task

When the JS stack empties, processRootScheduleInMicrotask runs. For each scheduled root it calls scheduleTaskForRootDuringMicrotask.30 That calls getNextLanes, which returns DefaultLane. The result doesn't include SyncLane, so instead of flushing in the microtask, it maps the lane's event priority to NormalPriority and calls scheduleCallback(NormalPriority, performWorkOnRootViaSchedulerTask.bind(null, root)).31 The Scheduler pushes the task onto its min-heap and posts a MessageChannel message.3233

→ deep dive: Lanes & Scheduler

9. Scheduler task → performWorkOnRoot, and why it is not time-sliced

In a new macrotask, performWorkUntilDeadline → flushWork → workLoop calls the callback.343536 Between the microtask and this task the browser could paint, so any static HTML shell stays visible. performWorkOnRootViaSchedulerTask flushes leftover passive effects (none), recomputes getNextLanes (still DefaultLane), and calls performWorkOnRoot(root, lanes, forceSync).37

Now the important decision:38

JavaScript
const shouldTimeSlice =
  (!forceSync && !includesBlockingLane(lanes) && !includesExpiredLane(root, lanes)) ||
  checkIfRootIsPrerendering(root, lanes);
let exitStatus = shouldTimeSlice
  ? renderRootConcurrent(root, lanes)
  : renderRootSync(root, lanes, true);

includesBlockingLane tests against SyncLane | InputContinuousLane | DefaultLane (plus their hydration variants and GestureLane).39 DefaultLane is in that set, so shouldTimeSlice is false and the first render runs through renderRootSync, in one go, never checking shouldYield(). Only transition, retry, idle and offscreen lanes reach renderRootConcurrent.

→ deep dive: Work Loop

10. prepareFreshStack: a WIP HostRoot, and the update gets linked

renderRootSync sets executionContext |= RenderContext, pushes the dispatcher, and sees workInProgressRoot !== root, so it calls prepareFreshStack(root, DefaultLane).4041 Two things matter there:

  • createWorkInProgress(root.current, null). The HostRoot has no alternate, so a second HostRoot fiber is allocated and the two are cross-linked (wip.alternate = current, current.alternate = wip). The WIP copies updateQueue, memoizedState, lanes and child (null).42
  • finishQueueingConcurrentUpdates() drains concurrentQueues. The update is spliced into queue.shared.pending (a one-element circular list), and markUpdateLaneFromFiberToRoot would set childLanes on ancestors (the HostRoot has none).43
Text
FiberRoot.current ──► HostRoot(A)  ◄──alternate──►  HostRoot(B) = workInProgress
                      shared.pending ──► update ⟲ (both share the same queue.shared)

→ deep dive: Fiber, Work Loop

11. beginWork(HostRoot): the queue becomes the element

workLoopSync calls performUnitOfWork until workInProgress === null.4445 The first unit is HostRoot(B). beginWork sees current !== null, with identical (null) props, but checkScheduledUpdateOrContext finds DefaultLane in current.lanes, so it doesn't bail. It sets workInProgress.lanes = NoLanes and dispatches to updateHostRoot.46 updateHostRoot clones the queue and runs processUpdateQueue, which applies the payload: memoizedState = {element: <App/>, …}.4748 nextChildren is that element, so it calls reconcileChildren.

→ deep dive: beginWork & Reconciliation

12. Placement only on the top

The HostRoot has a current, so reconcileChildren uses reconcileChildFibers, the reconciler with side-effect tracking on.49 reconcileSingleElement finds no existing child, calls createFiberFromElement (a function without prototype.isReactComponent becomes a FunctionComponent fiber), and placeSingleChild sees alternate === null and sets Placement on the App fiber.50515253

Every fiber below App has current === null and goes through mountChildFibers, which is the same reconciler with tracking off. None of them get Placement. That's the setup for the single DOM insertion in step 18.

13. beginWork(App): renderWithHooks with the mount dispatcher

updateFunctionComponent calls renderWithHooks.54 renderWithHooks sets currentlyRenderingFiber = App, nulls memoizedState and updateQueue, and because current === null installs HooksDispatcherOnMount in ReactSharedInternals.H. Then it calls App(props).5556 Your code runs for the first time.

14. Hooks build the list

Each hook call appends a node via mountWorkInProgressHook.57

  • useState(0) → mountState → mountStateImpl creates the hook and its queue {pending: null, lanes: NoLanes, lastRenderedReducer: basicStateReducer, lastRenderedState: 0}, and binds dispatch = dispatchSetState.bind(null, App fiber, queue).5859 That bound function is the setCount your onClick closes over.
  • useLayoutEffect → mountLayoutEffect sets fiber flags Update | LayoutStatic and pushes an effect tagged HookHasEffect | HookLayout.6061
  • useEffect → mountEffect sets Passive | PassiveStatic and pushes an effect tagged HookHasEffect | HookPassive.6263
Text
App fiber
  flags: Placement | Update | Passive   (+ static flags)
  memoizedState ─► Hook{useState: 0, queue}
                    └ next ─► Hook{Effect{Layout|HasEffect, deps:[0]}}
                               └ next ─► Hook{Effect{Passive|HasEffect, deps:[0]}}
  updateQueue: {lastEffect ─► passive effect ⟲ layout effect, events: null, stores: null, memoCache: null}

App returns a <div> element. renderWithHooks swaps in ContextOnlyDispatcher (calling a hook now would throw) and returns it. reconcileChildren (mount variant) creates the div fiber.

→ deep dive: Hooks on Mount

15. Children all the way down

beginWork(div) → updateHostComponent pushes host context, and props.children is an array, so reconcileChildrenArray creates three fibers: Header, button and Footer.6465 Then the depth-first walk continues:

#UnitWhat happens
1B(HostRoot)update queue → <App/>, App fiber + Placement
2B(App)hooks mounted, <div> child
3B(div)3 children reconciled
4B(Header)Header({title: "Counter"}) → <h1> fiber
5B(h1)shouldSetTextContent → no child → returns null
6C(h1)DOM <h1> created. Sibling? None in Header → climb
7C(Header)bubbling only → sibling button
8B(button) → C(button)text child → no fiber. DOM <button> created
9B(Footer) → B(footer) → C(footer) → C(Footer)same shape as Header
10C(div)DOM <div>, appends h1, button, footer
11C(App), C(HostRoot)bubbling. workInProgress = null → RootCompleted

performUnitOfWork copies pendingProps into memoizedProps after each beginWork. That stored props object is what the next render compares by identity.45 completeUnitOfWork drives the climb.66

→ deep dive: Work Loop, beginWork & Reconciliation

16. completeWork: DOM built off-screen

For each HostComponent with no current, completeWork calls createInstance (document.createElement, plus precacheFiberNode and updateFiberProps, which write the __reactFiber$ and __reactProps$ expandos the event system reads later).67686970 It then calls appendAllChildren, which appends the nearest host descendants through component fibers. At div it skips over Header and Footer and appends h1, button and footer.7172 Finally finalizeInitialChildren → setInitialProperties sets attributes, and setProp('children', 0) sets the button's textContent to "0".737475 onClick isn't an attribute. setProp only installs Safari's no-op onclick workaround (trapClickOnNonInteractiveElement).76

Text
detached:  <div><h1>Counter</h1><button>0</button><footer>made with React</footer></div>
document:  <div id="root"></div>         ← untouched so far

At the HostRoot, completeWork sees a first client render (current.child === null, not hydrating) and sets Snapshot, which schedules clearContainer for the commit.77

→ deep dive: completeWork

17. bubbleProperties: summaries for the commit

Every completeWork ends with bubbleProperties, which ORs children's flags | subtreeFlags into the parent's subtreeFlags and merges lanes | childLanes into childLanes.78 At the end:

Text
HostRoot(B)  flags: Snapshot      subtreeFlags: Placement|Update|Passive|…   childLanes: 0
App          flags: Placement|Update|Passive
div, h1, button, footer, Header, Footer: no mutation flags

The commit can now ask "is there anything of kind X below here?" with one bitmask test per fiber.

18. Commit: completeRoot → commitRoot, mutation, the single insertion

performWorkOnRoot reads finishedWork = root.current.alternate and calls finishConcurrentRender (used for sync renders too). That calls completeRootWhenReady → completeRoot → commitRoot.7980818283 There is no commitRootImpl in 19.3.1. commitRoot:

  1. markRootFinished clears DefaultLane from pendingLanes.
  2. The tree has Passive in its subtreeFlags, so it posts scheduleCallback(NormalSchedulerPriority, flushPassiveEffects) before any DOM work.84
  3. Before mutation: commitBeforeMutationEffects disables React's event system, and for the HostRoot's Snapshot calls clearContainer, removing any placeholder markup.858687
  4. Mutation: flushMutationEffects → commitMutationEffects → commitMutationEffectsOnFiber recurses where subtreeFlags & MutationMask, and commitReconciliationEffects finds Placement on App.88899091 commitHostPlacement → commitPlacement finds the host parent (the HostRoot, so containerInfo). App isn't a host node, so insertOrAppendPlacementNodeIntoContainer recurses to div and calls appendChildToContainer(container, div).92939495 That one call puts the whole UI into the document.

→ deep dive: Commit Phase

19. The swap: root.current = finishedWork

At the end of flushMutationEffects, React does root.current = finishedWork (line 4051).96 It sits after mutation, so unmount-style code would still see the old tree, and before layout, so useLayoutEffect and refs see the new one. HostRoot(B) is now current. HostRoot(A) is the scratch buffer for the next render.

20. Layout effects (before paint)

flushLayoutEffects → commitLayoutEffects → commitLayoutEffectOnFiber, children first, pruned by LayoutMask.979899 App has Update, so commitHookLayoutEffects → commitHookEffectListMount(HookLayout | HookHasEffect) runs the layout create function: document.title = "count: 0".100101 The DOM is live, so measuring here returns real geometry (with a forced synchronous layout). Nothing has painted.

21. flushSpawnedWork → requestPaint → pixels

flushSpawnedWork calls requestPaint(), which sets Scheduler's needsPaint.102103 The lanes were DefaultLane, not SyncLane, so the "flush passive effects synchronously" branch is skipped.104 The task returns. Back in Scheduler's workLoop, shouldYieldToHost() now returns true because needsPaint is set, so Scheduler stops and posts another message instead of running the passive-effects task straight away.105 The JS task ends, and the browser runs style, layout and paint. The user sees "Counter / 0 / made with React".

22. Passive effects in the next task

The next Scheduler macrotask runs the callback posted in step 18: flushPassiveEffects → flushPassiveEffectsImpl. That first runs all passive unmounts (none), then commitPassiveMountEffects → commitPassiveMountOnFiber → commitHookPassiveMountEffects → your useEffect create function. console.log("committed", 0).106107108109 A setState here would get at most DefaultLane and be batched normally.

With the React Compiler

This is the real babel-plugin-react-compiler@1.0.0 output for the example (file compile-demo/examples/Walkthrough.jsx → out-Walkthrough.js in the scratchpad, verbatim apart from the comments I added):

JavaScript
import { c as _c } from "react/compiler-runtime";
function Header(t0) {
  const $ = _c(2);
  const { title } = t0;
  let t1;
  if ($[0] !== title) {
    t1 = <h1>{title}</h1>;
    $[0] = title;
    $[1] = t1;
  } else {
    t1 = $[1];
  }
  return t1;
}
function Footer() {
  const $ = _c(1);
  let t0;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t0 = <footer>made with React</footer>;
    $[0] = t0;
  } else {
    t0 = $[0];
  }
  return t0;
}
function App() {
  const $ = _c(13);
  const [count, setCount] = useState(0);
  let t0;
  let t1;
  if ($[0] !== count) {
    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) {
    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")) {
    t4 = <Header title="Counter" />;        // no reactive inputs: built once per fiber
    t5 = () => setCount(_temp);             // setCount is stable, so the handler is too
    $[6] = t4; $[7] = t5;
  } else {
    t4 = $[6]; t5 = $[7];
  }
  let t6;
  if ($[8] !== count) {
    t6 = <button onClick={t5}>{count}</button>;
    $[8] = count; $[9] = t6;
  } else {
    t6 = $[9];
  }
  let t7;
  if ($[10] === Symbol.for("react.memo_cache_sentinel")) {
    t7 = <Footer />;
    $[10] = t7;
  } else {
    t7 = $[10];
  }
  let t8;
  if ($[11] !== t6) {
    t8 = <div>{t4}{t6}{t7}</div>;
    $[11] = t6; $[12] = t8;
  } else {
    t8 = $[12];
  }
  return t8;
}
function _temp(c) {
  return c + 1;                             // the updater, outlined to module scope
}

(I joined some short statements onto one line.) Now re-walk the 22 steps:

  • Steps 1–12: unchanged. The compiler doesn't touch react-dom, the root, lanes, the scheduler or beginWork(HostRoot). createRoot(...).render(<App />) at module level isn't inside a component, so it is left alone. The _c import resolves to useMemoCache: react/compiler-runtime is literally export {useMemoCache as c} from './ReactHooks'.110

  • Step 13: one extra hook call first. App() begins with _c(13), dispatched through the mount dispatcher to useMemoCache.56111 There is no cache on the fiber or its (nonexistent) alternate, so it allocates {data: [], index: 0}, creates the FunctionComponentUpdateQueue, stores the cache in fiber.updateQueue.memoCache, and returns a 13-slot array filled with REACT_MEMO_CACHE_SENTINEL (Symbol.for('react.memo_cache_sentinel')).111112 It doesn't add a node to the hook list.

  • Step 14: same hook list, plus cache filling. Every $[i] !== dep test is true against the sentinel, and every === sentinel test is true by definition, so every scope runs once and writes its slots. useState, useLayoutEffect and useEffect produce exactly the hook nodes and effect ring shown in step 14. The effects are pushed into the same updateQueue object that holds the cache.

    Text
    App.updateQueue.memoCache.data[0] =
      [0, layoutFn, [0], 0, passiveFn, [0], <Header/>, onClickFn, 0, <button/>, <Footer/>, <button/>, <div/>]
       0     1      2   3     4       5       6          7      8      9         10        11       12
  • Step 15: same walk. Header and Footer are compiled too. Header caches <h1> on title (_c(2)), and Footer caches <footer> behind a sentinel (_c(1)). Each fills its own cache on its own fiber. Every component still runs exactly once, and every fiber is created exactly as before: current === null everywhere, so no bailout check applies.

  • Steps 16–22: unchanged. Same DOM nodes, same single appendChildToContainer, same swap, same layout effect, same paint, same passive effect in the next task. The cached element objects simply become the pendingProps source for the fibers, as uncompiled elements would.

The cost on mount is three small arrays (13 + 2 + 1 slots), a few comparisons and stores, and the closures that would have been created anyway. The payoff shows up only on the next render, which is the subject of setState Walkthrough: the cached <Header/> and <Footer/> elements are what let React skip those subtrees.

Step by step comparison

StepWithout compilerWith compiler
1. JSX → elementsjsx() at render timeSame calls, wrapped in cache scopes
2. createRootFiberRoot + HostRoot + listenersSame
3–7. root.render → update → microtaskDefaultLane, buffered update, one microtaskSame
8–9. Scheduler task, renderRootSyncNot time-sliced (blocking lane)Same
10–12. prepareFreshStack, HostRoot, Placement on AppAs describedSame
13. renderWithHooks(App)Mount dispatcher → App()Same, but the first call is _c(13) → useMemoCache → sentinel-filled array on updateQueue.memoCache
14. Hooks3 hook nodes, 2 effectsSame 3 nodes and 2 effects, plus 13 cache slots written
15. ChildrenHeader and Footer run, 8 fibersSame, and Header/Footer fill _c(2)/_c(1) caches
16–17. completeWork, bubbling4 DOM nodes built off-screenSame
18–19. Commit, single appendChild, swapSameSame
20. Layout effectdocument.title = "count: 0"Same
21. PaintAfter the task endsSame
22. Passive effectNext Scheduler taskSame

Summary

createRoot(el).render(<App/>) renders nothing synchronously: it builds the root objects, attaches the listeners, buffers one DefaultLane update and books a microtask. The microtask books a Scheduler task, which renders the whole tree with renderRootSync, because DefaultLane is blocking and never time-sliced. The DOM is built off-screen, inserted with one appendChildToContainer, layout effects run, the browser paints, and useEffect runs in a later task. The compiler only adds cache filling; its payoff comes on the next render, in the click walkthrough.

Key takeaways

  • JSX and createRoot: elements are inert objects; createRoot builds the FiberRootNode and HostRoot fiber and calls listenToAllSupportedEvents before any component runs.
  • requestUpdateLane: with no React event and no transition, a top-level root.render resolves to DefaultEventPriority, which is DefaultLane.
  • Buffered update: enqueueConcurrentClassUpdate parks the update in concurrentQueues; prepareFreshStack links it into shared.pending only when the render starts (root.render).
  • One microtask, then a task: ensureRootIsScheduled queues one microtask, and scheduleTaskForRootDuringMicrotask books a NormalPriority Scheduler task because the lanes don't include SyncLane (Lanes & Scheduler).
  • Not time-sliced: includesBlockingLane covers DefaultLane, so shouldTimeSlice is false; createRoot makes the first render asynchronous, not interruptible (Work Loop).
  • Placement only on App: the HostRoot reconciles with side-effect tracking on, while everything below goes through mountChildFibers.
  • Hooks on mount: HooksDispatcherOnMount builds the hook list, binds setCount to dispatchSetState, and flags the fiber Update | Passive.
  • completeWork: createInstance writes the __reactFiber$/__reactProps$ expandos, appendAllChildren assembles the detached tree, and bubbleProperties summarises flags (completeWork).
  • Commit: passive effects are scheduled first, clearContainer runs, one appendChildToContainer inserts the UI, then root.current = finishedWork and layout effects run before paint (Commit Phase).
  • Paint, then useEffect: requestPaint makes Scheduler yield, the browser paints, and the passive effect runs in the next task because this commit was not SyncLane.

← Prev · Index · Next →

Footnotes

  1. shouldSetTextContent — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L744-L755 (opens in a new tab) ↩

  2. jsxProd — packages/react/src/jsx/ReactJSXElement.js#L291-L348 (opens in a new tab) ↩

  3. ReactElement — packages/react/src/jsx/ReactJSXElement.js#L170-L283 (opens in a new tab) ↩

  4. useState / resolveDispatcher in the react package — packages/react/src/ReactHooks.js#L24-L71 (opens in a new tab) ↩

  5. createRoot — packages/react-dom/src/client/ReactDOMRoot.js#L171-L262 (opens in a new tab) ↩

  6. createContainer — packages/react-reconciler/src/ReactFiberReconciler.js#L235-L280 (opens in a new tab) ↩

  7. createFiberRoot — packages/react-reconciler/src/ReactFiberRoot.js#L157-L236 (opens in a new tab) ↩

  8. FiberRootNode — packages/react-reconciler/src/ReactFiberRoot.js#L50-L155 (opens in a new tab) ↩

  9. createHostRootFiber — packages/react-reconciler/src/ReactFiber.js#L529-L551 (opens in a new tab) ↩

  10. initializeUpdateQueue — packages/react-reconciler/src/ReactFiberClassUpdateQueue.js#L176-L189 (opens in a new tab) ↩

  11. markContainerAsRoot — packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L113-L116 (opens in a new tab) ↩

  12. listenToAllSupportedEvents — packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L432-L459 (opens in a new tab) ↩

  13. ReactDOMRoot.prototype.render — packages/react-dom/src/client/ReactDOMRoot.js#L107-L136 (opens in a new tab) ↩

  14. updateContainer — packages/react-reconciler/src/ReactFiberReconciler.js#L353-L370 (opens in a new tab) ↩

  15. requestUpdateLane — packages/react-reconciler/src/ReactFiberWorkLoop.js#L812-L868 (opens in a new tab) ↩

  16. resolveUpdatePriority (DOM) — packages/react-dom-bindings/src/client/ReactDOMUpdatePriority.js#L35-L45 (opens in a new tab) ↩

  17. Event priorities defined as lanes — packages/react-reconciler/src/ReactEventPriorities.js#L24-L28 (opens in a new tab) ↩

  18. SyncLane, InputContinuousLane, DefaultLane — packages/react-reconciler/src/ReactFiberLane.js#L47-L54 (opens in a new tab) ↩

  19. updateContainerImpl — packages/react-reconciler/src/ReactFiberReconciler.js#L393-L458 (opens in a new tab) ↩

  20. createUpdate — packages/react-reconciler/src/ReactFiberClassUpdateQueue.js#L210-L221 (opens in a new tab) ↩

  21. enqueueUpdate (class queue) — packages/react-reconciler/src/ReactFiberClassUpdateQueue.js#L223-L274 (opens in a new tab) ↩

  22. enqueueUpdate (concurrent buffer) — packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L90-L113 (opens in a new tab) ↩

  23. getRootForUpdatedFiber — packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L252-L276 (opens in a new tab) ↩

  24. scheduleUpdateOnFiber — packages/react-reconciler/src/ReactFiberWorkLoop.js#L987-L1113 (opens in a new tab) ↩

  25. markRootUpdated — packages/react-reconciler/src/ReactFiberLane.js#L825-L849 (opens in a new tab) ↩

  26. ensureRootIsScheduled — packages/react-reconciler/src/ReactFiberRootScheduler.js#L116-L152 (opens in a new tab) ↩

  27. ensureScheduleIsScheduled — packages/react-reconciler/src/ReactFiberRootScheduler.js#L154-L169 (opens in a new tab) ↩

  28. scheduleImmediateRootScheduleTask — packages/react-reconciler/src/ReactFiberRootScheduler.js#L650-L696 (opens in a new tab) ↩

  29. scheduleMicrotask = queueMicrotask — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L871-L877 (opens in a new tab) ↩

  30. processRootScheduleInMicrotask — packages/react-reconciler/src/ReactFiberRootScheduler.js#L259-L348 (opens in a new tab) ↩

  31. scheduleTaskForRootDuringMicrotask — packages/react-reconciler/src/ReactFiberRootScheduler.js#L384-L509 (opens in a new tab) ↩

  32. unstable_scheduleCallback — packages/scheduler/src/forks/Scheduler.js#L335-L427 (opens in a new tab) ↩

  33. MessageChannel host callback — packages/scheduler/src/forks/Scheduler.js#L546-L554 (opens in a new tab) ↩

  34. performWorkUntilDeadline — packages/scheduler/src/forks/Scheduler.js#L498-L528 (opens in a new tab) ↩

  35. flushWork — packages/scheduler/src/forks/Scheduler.js#L145-L191 (opens in a new tab) ↩

  36. Scheduler workLoop — packages/scheduler/src/forks/Scheduler.js#L193-L266 (opens in a new tab) ↩

  37. performWorkOnRootViaSchedulerTask — packages/react-reconciler/src/ReactFiberRootScheduler.js#L513-L606 (opens in a new tab) ↩

  38. shouldTimeSlice in performWorkOnRoot — packages/react-reconciler/src/ReactFiberWorkLoop.js#L1165-L1181 (opens in a new tab) ↩

  39. includesBlockingLane — packages/react-reconciler/src/ReactFiberLane.js#L684-L694 (opens in a new tab) ↩

  40. renderRootSync — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2623-L2768 (opens in a new tab) ↩

  41. prepareFreshStack (createWorkInProgress at L2256, finishQueueingConcurrentUpdates at L2284) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2023-L2293 (opens in a new tab) ↩

  42. createWorkInProgress — packages/react-reconciler/src/ReactFiber.js#L323-L437 (opens in a new tab) ↩

  43. finishQueueingConcurrentUpdates — packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L50-L84 (opens in a new tab) ↩

  44. workLoopSync — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2772-L2777 (opens in a new tab) ↩

  45. performUnitOfWork — packages/react-reconciler/src/ReactFiberWorkLoop.js#L3081-L3123 (opens in a new tab) ↩ ↩2

  46. beginWork (bailout gate L4250-L4320, tag switch after) — packages/react-reconciler/src/ReactFiberBeginWork.js#L4226-L4512 (opens in a new tab) ↩

  47. updateHostRoot — packages/react-reconciler/src/ReactFiberBeginWork.js#L1831-L1949 (opens in a new tab) ↩

  48. processUpdateQueue — packages/react-reconciler/src/ReactFiberClassUpdateQueue.js#L487-L698 (opens in a new tab) ↩

  49. reconcileChildren — packages/react-reconciler/src/ReactFiberBeginWork.js#L339-L370 (opens in a new tab) ↩

  50. reconcileSingleElement — packages/react-reconciler/src/ReactChildFiber.js#L1703-L1806 (opens in a new tab) ↩

  51. createFiberFromElement — packages/react-reconciler/src/ReactFiber.js#L739-L765 (opens in a new tab) ↩

  52. createFiberFromTypeAndProps (defaults to FunctionComponent) — packages/react-reconciler/src/ReactFiber.js#L554-L737 (opens in a new tab) ↩

  53. placeSingleChild — packages/react-reconciler/src/ReactChildFiber.js#L543-L550 (opens in a new tab) ↩

  54. updateFunctionComponent — packages/react-reconciler/src/ReactFiberBeginWork.js#L1453-L1562 (opens in a new tab) ↩

  55. renderWithHooks (dispatcher choice at L562-L566) — packages/react-reconciler/src/ReactFiberHooks.js#L505-L634 (opens in a new tab) ↩

  56. HooksDispatcherOnMount — packages/react-reconciler/src/ReactFiberHooks.js#L3926-L3952 (opens in a new tab) ↩ ↩2

  57. mountWorkInProgressHook — packages/react-reconciler/src/ReactFiberHooks.js#L983-L1002 (opens in a new tab) ↩

  58. mountState — packages/react-reconciler/src/ReactFiberHooks.js#L1948-L1960 (opens in a new tab) ↩

  59. mountStateImpl — packages/react-reconciler/src/ReactFiberHooks.js#L1920-L1946 (opens in a new tab) ↩

  60. mountLayoutEffect — packages/react-reconciler/src/ReactFiberHooks.js#L2796-L2808 (opens in a new tab) ↩

  61. mountEffectImpl — packages/react-reconciler/src/ReactFiberHooks.js#L2640-L2655 (opens in a new tab) ↩

  62. mountEffect — packages/react-reconciler/src/ReactFiberHooks.js#L2697-L2719 (opens in a new tab) ↩

  63. pushSimpleEffect — packages/react-reconciler/src/ReactFiberHooks.js#L2588-L2603 (opens in a new tab) ↩

  64. updateHostComponent (begin phase) — packages/react-reconciler/src/ReactFiberBeginWork.js#L1966-L2033 (opens in a new tab) ↩

  65. reconcileChildrenArray — packages/react-reconciler/src/ReactChildFiber.js#L1177-L1369 (opens in a new tab) ↩

  66. completeUnitOfWork — packages/react-reconciler/src/ReactFiberWorkLoop.js#L3368-L3431 (opens in a new tab) ↩

  67. completeWork, HostComponent case — packages/react-reconciler/src/ReactFiberCompleteWork.js#L1374-L1472 (opens in a new tab) ↩

  68. createInstance — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L534-L664 (opens in a new tab) ↩

  69. precacheFiberNode — packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L94-L111 (opens in a new tab) ↩

  70. updateFiberProps — packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L295-L301 (opens in a new tab) ↩

  71. appendAllChildren — packages/react-reconciler/src/ReactFiberCompleteWork.js#L245-L352 (opens in a new tab) ↩

  72. appendInitialChild — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L695-L701 (opens in a new tab) ↩

  73. finalizeInitialChildren — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L703-L721 (opens in a new tab) ↩

  74. setInitialProperties — packages/react-dom-bindings/src/client/ReactDOMComponent.js#L1111-L1496 (opens in a new tab) ↩

  75. setProp, case 'children' (string/number → setTextContent) — packages/react-dom-bindings/src/client/ReactDOMComponent.js#L406-L434 (opens in a new tab) ↩

  76. setProp, case 'onClick' → trapClickOnNonInteractiveElement — packages/react-dom-bindings/src/client/ReactDOMComponent.js#L618-L627 (opens in a new tab) ↩

  77. completeWork, HostRoot case (Snapshot at L1178) — packages/react-reconciler/src/ReactFiberCompleteWork.js#L1119-L1198 (opens in a new tab) ↩

  78. bubbleProperties — packages/react-reconciler/src/ReactFiberCompleteWork.js#L793-L914 (opens in a new tab) ↩

  79. performWorkOnRoot (finishedWork = root.current.alternate at L1222) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L1137-L1325 (opens in a new tab) ↩

  80. finishConcurrentRender — packages/react-reconciler/src/ReactFiberWorkLoop.js#L1420-L1576 (opens in a new tab) ↩

  81. completeRootWhenReady — packages/react-reconciler/src/ReactFiberWorkLoop.js#L1578-L1701 (opens in a new tab) ↩

  82. completeRoot — packages/react-reconciler/src/ReactFiberWorkLoop.js#L3511-L3723 (opens in a new tab) ↩

  83. commitRoot — packages/react-reconciler/src/ReactFiberWorkLoop.js#L3725-L3922 (opens in a new tab) ↩

  84. commitRoot posting the passive-effects callback — packages/react-reconciler/src/ReactFiberWorkLoop.js#L3771-L3820 (opens in a new tab) ↩

  85. commitBeforeMutationEffects — packages/react-reconciler/src/ReactFiberCommitWork.js#L352-L371 (opens in a new tab) ↩

  86. commitBeforeMutationEffectsOnFiber — packages/react-reconciler/src/ReactFiberCommitWork.js#L483-L579 (opens in a new tab) ↩

  87. clearContainer — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L3972-L3988 (opens in a new tab) ↩

  88. flushMutationEffects — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4009-L4053 (opens in a new tab) ↩

  89. commitMutationEffects — packages/react-reconciler/src/ReactFiberCommitWork.js#L2027-L2044 (opens in a new tab) ↩

  90. commitMutationEffectsOnFiber — packages/react-reconciler/src/ReactFiberCommitWork.js#L2072-L2843 (opens in a new tab) ↩

  91. commitReconciliationEffects — packages/react-reconciler/src/ReactFiberCommitWork.js#L2845-L2864 (opens in a new tab) ↩

  92. commitHostPlacement — packages/react-reconciler/src/ReactFiberCommitHostEffects.js#L585-L595 (opens in a new tab) ↩

  93. commitPlacement — packages/react-reconciler/src/ReactFiberCommitHostEffects.js#L452-L543 (opens in a new tab) ↩

  94. insertOrAppendPlacementNodeIntoContainer — packages/react-reconciler/src/ReactFiberCommitHostEffects.js#L330-L392 (opens in a new tab) ↩

  95. appendChildToContainer — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L1082-L1133 (opens in a new tab) ↩

  96. root.current = finishedWork — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4047-L4051 (opens in a new tab) ↩

  97. flushLayoutEffects — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4055-L4152 (opens in a new tab) ↩

  98. commitLayoutEffects — packages/react-reconciler/src/ReactFiberCommitWork.js#L3063-L3078 (opens in a new tab) ↩

  99. commitLayoutEffectOnFiber — packages/react-reconciler/src/ReactFiberCommitWork.js#L600-L934 (opens in a new tab) ↩

  100. commitHookLayoutEffects — packages/react-reconciler/src/ReactFiberCommitEffects.js#L97-L112 (opens in a new tab) ↩

  101. commitHookEffectListMount — packages/react-reconciler/src/ReactFiberCommitEffects.js#L141-L247 (opens in a new tab) ↩

  102. flushSpawnedWork (requestPaint() at L4189) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4154-L4437 (opens in a new tab) ↩

  103. Scheduler requestPaint — packages/scheduler/src/forks/Scheduler.js#L474-L479 (opens in a new tab) ↩

  104. Sync-lane passive flush in flushSpawnedWork — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4316-L4329 (opens in a new tab) ↩

  105. shouldYieldToHost — packages/scheduler/src/forks/Scheduler.js#L459-L472 (opens in a new tab) ↩

  106. flushPassiveEffects — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4691-L4726 (opens in a new tab) ↩

  107. flushPassiveEffectsImpl — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4728-L4875 (opens in a new tab) ↩

  108. commitPassiveMountEffects — packages/react-reconciler/src/ReactFiberCommitWork.js#L3764-L3780 (opens in a new tab) ↩

  109. commitHookPassiveMountEffects — packages/react-reconciler/src/ReactFiberCommitEffects.js#L305-L316 (opens in a new tab) ↩

  110. react/compiler-runtime re-export — packages/react/src/ReactCompilerRuntime.js#L10 (opens in a new tab) ↩

  111. useMemoCache — packages/react-reconciler/src/ReactFiberHooks.js#L1181-L1263 (opens in a new tab) ↩ ↩2

  112. REACT_MEMO_CACHE_SENTINEL — packages/shared/ReactSymbols.js#L41-L43 (opens in a new tab) ↩