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
root.render()setState/dispatch- lane picked, update queued
- call components (
beginWork) - reconcile children (diff)
- prepare DOM (
completeWork)
- before mutation
- mutation: DOM writes
- swap trees
- layout:
useLayoutEffect, refs
- style / layout
- paint
- passive effects
useEffect
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):
- “It’s the component’s initial render” (
root.render(<App />)). - “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):
- Going down (
beginWork), React calls the component, or bails out if nothing changed, and reconciles the returned elements against the existing child fibers. Reconciling means deciding what to reuse, update, create or delete (Reconciliation, Child Reconciliation Algorithm). - Coming back up (
completeWork), React creates DOM nodes for new host elements (not yet attached to the document), flags existing ones that got new props, and bubbles “something below me needs work” flags to the parent.
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:
- render phase
startTransition(() => setCurrency('EUR'))The update is a transition, so it renders in ~5ms slices. - render phase
slice 1: performUnitOfWork(Tea)The first row is rendered into the work-in-progress tree. The screen is untouched. - render phase
slice 2: performUnitOfWork(Cake)Second row. Still nothing visible: the user sees a consistent dollar list. - event
keydown → urgent update → prepareFreshStack()An urgent update arrives. React throws the half-built tree away. Nothing was shown, so there is nothing to undo. - render phase
restart from the rootAfter the urgent update, the transition starts over and renders all three rows. - commit phase
commitRoot()Commit applies every change at once, synchronously. The screen jumps from all-dollars to all-euros. Never a mix.
- render phase
setCurrency('EUR')Same update, but each component writes its DOM as soon as it renders. - render phase
render(Tea) → write DOMRow one changes on screen immediately. - render phase
render(Cake) → write DOMRow two. The user now sees euros and dollars in the same list. - event
keydown → must stop nowAn urgent update arrives. Pausing leaves the screen half-updated; aborting would mean undoing DOM writes. Neither is safe. - render phase
— why React splits the phases —So React keeps all pausable, abortable work in the render phase (pure, off-screen), and makes the commit phase small, synchronous and atomic.
- “pause work and come back to it later”
- “assign priority to different types of work”
- “reuse previously completed work”
- “abort work if it’s no longer needed”
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:
- render phase
Counter() → <button>{0}</button>Render 1. JSX produces a React element: a cheap, immutable plain object describing what you want. - render phase
createFiberFromElement(element)React creates a fiber for it: a long-lived, mutable record of this button’s position, props and effects. - render phase
completeWork → document.createElement('button')The real DOM node is created off-screen and linked fromfiber.stateNode. - commit phase
appendChild(container, button)Commit puts it on screen. The element has done its job and is thrown away. - render phase
Counter() → <button>{1}</button>Render 2 creates a brand-new element object. Elements are never reused. - render phase
reconcile: 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. - commit phase
commitUpdate(fiber.stateNode, …)Commit updates the same DOM node (#1) throughstateNode. Element 2 is thrown away too. Fiber and DOM node live on; elements come and go.
| 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
setCount(1)The click handler queues an update onCounter’s state hook withSyncLaneand schedules the root in a microtask. Nothing renders yet.stack: dispatchDiscreteEvent › dispatchEvent › onClick › dispatchSetState - render phase
beginWork(HostRoot) → bail out, clone childrenRender starts at the root. Nothing to do on the way down toCounter, so those fibers are cloned into the work-in-progress tree.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(HostRoot) › bailoutOnAlreadyFinishedWork - render phase
Counter() → useState returns 1React calls your component. That is all “rendering” means.useStatereturns 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 - render phase
reconcileChildren: <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 - render phase
completeWork(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") - commit phase
commitTextUpdate(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 - commit phase
root.current = finishedWorkThe work-in-progress tree becomescurrent. Layout phase: no layout effects or refs here.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects - after paint
flushPassiveEffects() (SyncLane: at the end of the commit)NormallyuseEffectruns 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 - browser
browser: style → layout → paintThe microtask ends and the browser paints. Only now do the pixels show 1.stack:
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
- Render is pure, commit is not. Anything observable (subscriptions, mutations, requests) belongs in effects or event handlers.
- Re-render ≠ DOM update. A component can render and produce identical output. The commit then changes nothing. The cost you pay is the render itself.
- The DOM is never read to diff. If you mutate React-owned DOM behind React’s back, React won’t notice and may overwrite it.
- Strict Mode double-invokes renders (and effects on mount) in development only, to surface impurity.
Sources
- react.dev: Render and Commit
- Andrew Clark: React Fiber Architecture
- React 19.2.5 source:
react-dom/cjs/react-dom-client.development.js(performWorkOnRoot,commitRoot,flushSpawnedWork)
Primary source: react.dev/learn/render-and-commit