Must knowReact Internals · 02

Render and Commit

The pipeline: trigger → render → commit → paint → effects, why render must be pure, and what the "virtual DOM" really is.

This section:trigger→schedule→render→commit→effects
On this page · 6 sections
  1. Interview Q&A
  2. The pipeline
  3. Why render and commit are separate
  4. “The virtual DOM”, precisely
  5. Render and commit, step by step
  6. Rules and caveats

Notes on the pipeline every React update goes through, from react.dev’s Render and Commit, Andrew Clark’s React Fiber Architecture, and the React 19.2 source (react-dom-client.development.js). This is the entry point of React Internals. The later notes zoom into each box of the diagram below. My own notes and clarifications are marked with > 💬.

Interview Q&A

What does "rendering" mean in React?

Calling your components. react.dev: “‘Rendering’ is React calling your components.” It produces a description of the UI (React elements). It does not touch the DOM, and it is not what the browser calls rendering. The browser step is called painting. In our example, a Counter re-rendering means Counter() ran again, not that its <button> was rebuilt.

What are the phases of a React update?

Trigger, render, commit, then the browser paints and passive effects (useEffect) run. The render phase is pure and can be paused, repeated or thrown away. The commit phase is synchronous and is the only place the DOM changes.

Why must components be pure?

Because React is allowed to call them more times than you expect: twice in Strict Mode, repeatedly while a transition keeps getting interrupted, or not at all if it bails out. A render that mutates outside variables or does I/O would do so a random number of times. Side effects go in event handlers or effects, which run once per commit.

Is the "virtual DOM" diffed against the real DOM?

No. React compares the new elements with the previous render’s fiber tree, which is its own in-memory record. It never reads the DOM to decide what changed. See Reconciliation.

The pipeline

1Trigger
  • root.render()
  • setState / dispatch
  • lane picked, update queued
2Render phase
pure · interruptible
  • call components (beginWork)
  • reconcile children (diff)
  • prepare DOM (completeWork)
3Commit phase
sync · atomic
  • before mutation
  • mutation: DOM writes
  • swap trees
  • layout: useLayoutEffect, refs
4Browser
  • style / layout
  • paint
5After paint
  • passive effects
  • useEffect
Every update goes through the same five steps. Only the render phase can pause.

react.dev describes the first three steps with a restaurant analogy. Triggering delivers the guest’s order to the kitchen, rendering prepares the order in the kitchen, and committing places it on the table.

1. Trigger

There are only two reasons a component renders (react.dev):

  1. “It’s the component’s initial render” (root.render(<App />)).
  2. “The component’s (or one of its ancestors’) state has been updated.”

Context changes and a parent re-rendering are really cases of (2): someone’s state changed and React re-rendered from there downward. Props never trigger anything by themselves. See React Re-rendering.

Internally a trigger doesn’t start work right away. setState creates an update object, assigns it a priority (lane), marks the path from the component to the root, and asks the root to be scheduled. Several setStates in the same event land in the same lane and produce one render (automatic batching). See Scheduler, Lanes and Batching.

2. Render phase

react.dev: “On initial render, React will call the root component. For subsequent renders, React will call the function component whose state update triggered the render. This process is recursive.”

Under the hood React walks the fiber tree one fiber at a time (The Work Loop):

Everything in this phase is built on a work-in-progress copy of the tree (React Fiber, double buffering), so the screen keeps showing the old UI the whole time. That’s what makes the phase safe to interrupt: nothing has been shown yet, so there is nothing to undo.

3. Commit phase

When the work-in-progress tree is complete, React commits it in one synchronous pass. It applies the collected DOM mutations, swaps the work-in-progress tree to be the current one, attaches refs and runs layout effects. See Commit Phase and Effects.

react.dev: “React only changes the DOM nodes if there’s a difference between renders. For example, if a component re-renders but the <input> element remains in the same position in the JSX, React won’t touch it—or its value.”

4. Paint, then passive effects

The browser recalculates styles and layout and paints. react.dev calls this “painting” “to avoid confusion” with React’s rendering. After the paint, React runs useEffect callbacks. That ordering is why effects don’t block the user from seeing the update.

Why render and commit are separate

The split exists so the expensive, error-prone part (running user code and diffing) can be done without the user seeing anything, while the part the user does see is applied all at once. Andrew Clark’s architecture doc lists the goals Fiber was built for:

React: render, then commit
work-in-progress
Tea · €9Cake · €18Pie · €27
screen
Tea · $10Cake · $20Pie · $30
status
idle
  1. render phasestartTransition(() => setCurrency('EUR'))The update is a transition, so it renders in ~5ms slices.
  2. render phaseslice 1: performUnitOfWork(Tea)The first row is rendered into the work-in-progress tree. The screen is untouched.
  3. render phaseslice 2: performUnitOfWork(Cake)Second row. Still nothing visible: the user sees a consistent dollar list.
  4. eventkeydown → urgent update → prepareFreshStack()An urgent update arrives. React throws the half-built tree away. Nothing was shown, so there is nothing to undo.
  5. render phaserestart from the rootAfter the urgent update, the transition starts over and renders all three rows.
  6. commit phasecommitRoot()Commit applies every change at once, synchronously. The screen jumps from all-dollars to all-euros. Never a mix.
createdupdated / moveddeletedokwrong

0 / 0
Why render and commit are separate: rendering off-screen is what makes pausing and aborting safe.

All four are only possible in the render phase. A half-applied DOM mutation can’t be paused or aborted, so the commit phase never is. That’s why the render phase must be pure. It’s also why React can show a consistent UI while rendering concurrently (Concurrent Rendering).

The same doc separates reconciliation (“the algorithm React uses to diff one tree with another to determine which parts need to be changed”) from rendering to a host. The reconciler (react-reconciler) is shared, and React DOM and React Native are just different renderers plugged into it. The commit phase calls renderer methods like appendChild, commitUpdate and removeChild, and only the renderer knows what those mean.

“The virtual DOM”, precisely

“Virtual DOM” is a loose name for three different things:

React elements
{ type: "button", props: { children: 0 } }{ type: "button", props: { children: 1 } }
fiber
button fibermemoizedProps: –flag
stateNode → DOM node #1
DOM node
<button>0</button> · node #1
  1. render phaseCounter() → <button>{0}</button>Render 1. JSX produces a React element: a cheap, immutable plain object describing what you want.
  2. render phasecreateFiberFromElement(element)React creates a fiber for it: a long-lived, mutable record of this button’s position, props and effects.
  3. render phasecompleteWork → document.createElement('button')The real DOM node is created off-screen and linked from fiber.stateNode.
  4. commit phaseappendChild(container, button)Commit puts it on screen. The element has done its job and is thrown away.
  5. render phaseCounter() → <button>{1}</button>Render 2 creates a brand-new element object. Elements are never reused.
  6. render phasereconcile: new element vs current fiberReact diffs the new element against the fiber, not against the DOM. Same type → the same fiber is kept, with new props, and flagged Update.
  7. commit phasecommitUpdate(fiber.stateNode, …)Commit updates the same DOM node (#1) through stateNode. Element 2 is thrown away too. Fiber and DOM node live on; elements come and go.
current stepbeing comparedcreatedreused / keptupdated / movedcompleted

0 / 0
Elements are recreated every render; fibers and DOM nodes persist. React diffs elements against fibers.
Thing What it is Lifetime
React element { $$typeof, type, key, props } returned by JSX. A cheap, immutable description created every render, then thrown away
Fiber a mutable node that records a component’s position, state, hooks, props and effects lives as long as the component is mounted, reused across renders
DOM node the real browser object lives as long as the host fiber, referenced from fiber.stateNode

React diffs new elements vs current fibers, not “virtual DOM vs real DOM”. The output of that diff is a set of flags on the work-in-progress fibers, which the commit phase turns into DOM calls.

Render and commit, step by step

A counter button is clicked once:

trigger→render→commit→paint→effects
current tree (on screen)
HostRoot
Countercount: 0
button
"0"
work-in-progress
HostRoot
Countercount: 0flag
button
"0"flag
browser
DOM
<button>0</button>
pixels
0
document.title
"Clicked 0"
  1. triggersetCount(1)The click handler queues an update on Counter’s state hook with SyncLane and schedules the root in a microtask. Nothing renders yet.stack: dispatchDiscreteEvent › dispatchEvent › onClick › dispatchSetState
  2. render phasebeginWork(HostRoot) → bail out, clone childrenRender starts at the root. Nothing to do on the way down to Counter, so those fibers are cloned into the work-in-progress tree.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(HostRoot) › bailoutOnAlreadyFinishedWork
  3. render phaseCounter() → useState returns 1React calls your component. That is all “rendering” means. useState returns 1 on the work-in-progress fiber; the current one still says 0.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter() › updateReducer
  4. render phasereconcileChildren: <button> vs button fiber → same type, reuseThe returned <button> is compared with the existing button fiber: same type, so the fiber is reused with new props. Its text child goes from "0" to "1".stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › reconcileChildren › reconcileChildFibers › reconcileSingleElement › useFiber
  5. render phasecompleteWork(text) → Update · effect deps changed → PassiveOn the way back up, the changes are only recorded as flags. The DOM, and the screen, still show 0.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork("1")
  6. commit phasecommitTextUpdate(textNode, "0", "1")Commit, mutation: the one flagged DOM write happens. The DOM now says 1, but the browser hasn’t painted it yet.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitTextUpdate
  7. commit phaseroot.current = finishedWorkThe work-in-progress tree becomes current. Layout phase: no layout effects or refs here.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects
  8. after paintflushPassiveEffects() (SyncLane: at the end of the commit)Normally useEffect runs after paint. For a discrete event like a click, React 18+ flushes it synchronously at the end of the commit: document.title = "Clicked 1".stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushPassiveEffects › commitHookEffectListMount
  9. browserbrowser: style → layout → paintThe microtask ends and the browser paints. Only now do the pixels show 1.stack:
current stepcomponent runningreused / keptupdated / movedbailed outcompletedok

0 / 0
Render works on a copy and only records changes; commit writes the DOM; the browser paints afterwards.
function Counter() {
	const [count, setCount] = useState(0)
	useEffect(() => {
		document.title = `Clicked ${count}`
	}, [count])
	return <button onClick={() => setCount(count + 1)}>{count}</button>
}
# Phase What happens
1 trigger the click handler calls setCount(1). React queues an update on Counter’s state hook with SyncLane (a click is a discrete event) and schedules the root in a microtask
2 render React starts at the root, finds nothing to do on the way down to Counter and clones those fibers (bailout). It calls Counter(), and useState returns 1
3 render reconciling <button> against the existing button fiber: same type, so the fiber is reused with new props. The text child changes "0" → "1"
4 render completeWork on the text fiber flags an update (the text changed). The useEffect deps changed, so the fiber is flagged Passive
5 commit mutation: set the text node’s value to "1". Then root.current points to the new tree
6 commit layout: no layout effects or refs here
7 paint the browser paints “1”
8 effects document.title = 'Clicked 1'. For a discrete event like a click, React 18+ flushes these passive effects synchronously at the end of the commit rather than waiting for a later task

The end-to-end version with real function names is in A State Update, End to End.

Rules and caveats

Sources

Primary source: react.dev/learn/render-and-commit