One small, real app followed from the code you write to the pixels on screen: what Babel turns your JSX into, when the element objects
{}are created, what runs first when the page opens, how React decides which component to call, and what happens on a click, a context change, a list edit and a page switch. Each step has a plain-English story and an Under the hood block with the real React 19.2.5 functions and objects. Nothing here is hand-written guesswork: the compiled code, the element objects, the fiber trees and every call order were recorded by running the demo app (see “How this was recorded” at the end).
The whole journey in one picture
- Compile time. Babel (or Vite’s Oxc) rewrites every
<Tag />into a function call:jsx(Tag, props). Your source no longer contains JSX. - Page load. The browser runs
main.jsx.createRootsets React up,<App />becomes an object, androot.render()schedules a render. No component has run yet. - Render phase. React walks a tree of fibers. When it reaches a
component fiber it calls your function, which runs its
jsx()calls and returns new element objects. React compares them with what was there before and records what must change. - Commit phase. React applies the recorded changes to the DOM in one
synchronous pass, runs layout effects, and lets the browser paint.
useEffectruns after. - Updates.
setStatequeues an update and schedules another render that starts from the root but only calls the components that need it.
The rest of this note is each of those steps, in order, on a real app.
0. The app
Two pages switched with state (no router), a context, a component that
receives children, effects with cleanups, and a keyed list. React and
ReactDOM are pinned to 19.2.5. There is no <StrictMode>, so every
component runs once per render and the traces stay readable.
main.jsx, the entry point:
import { createRoot } from 'react-dom/client'
import { App } from './App.jsx'
import './styles.css'
const container = document.getElementById('root')
const root = createRoot(container)
root.render(<App />)
App.jsx owns the two pieces of top-level state: which page is shown, and
the theme.
import { useState } from "react";
import { ThemeContext } from "./ThemeContext.js";
import { Layout } from "./Layout.jsx";
import { Nav } from "./Nav.jsx";
import { CounterPage } from "./CounterPage.jsx";
import { TodosPage } from "./TodosPage.jsx";
import { log } from "./trace.js";
export function App() {
log("render App");
const [page, setPage] = useState("counter");
const [theme, setTheme] = useState("light");
return (
<ThemeContext value={theme}>
<Layout>
<Nav page={page} onNavigate={setPage} />
<button
className="theme"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
>
Theme: {theme}
</button>
{page === "counter" ? <CounterPage /> : <TodosPage />}
</Layout>
</ThemeContext>
);
}
Layout.jsx reads the theme from context and renders whatever it gets as
children:
import { useContext } from 'react'
import { ThemeContext } from './ThemeContext.js'
import { log } from './trace.js'
export function Layout({ children }) {
log('render Layout')
const theme = useContext(ThemeContext)
return (
<div className={`layout ${theme}`}>
<h1>How React Works</h1>
<main>{children}</main>
</div>
)
}
Nav.jsx gets the current page and a callback through props:
import { log } from './trace.js'
export function Nav({ page, onNavigate }) {
log('render Nav', page)
return (
<nav>
<button aria-current={page === 'counter'} onClick={() => onNavigate('counter')}>
Counter
</button>
<button aria-current={page === 'todos'} onClick={() => onNavigate('todos')}>
Todos
</button>
</nav>
)
}
CounterPage.jsx has state, a ref, a layout effect, an effect with a
cleanup, and a child component:
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { log } from './trace.js'
export function CounterPage() {
log('render CounterPage')
const [count, setCount] = useState(0)
const buttonRef = useRef(null)
useLayoutEffect(() => {
log('layout effect: button is', buttonRef.current.offsetWidth, 'px wide')
}, [count])
useEffect(() => {
log('effect: set title', count)
document.title = `Clicked ${count} times`
return () => log('cleanup: title effect', count)
}, [count])
return (
<section>
<h2>Counter</h2>
<Display value={count} />
<button ref={buttonRef} onClick={() => setCount(count + 1)}>
Add one
</button>
</section>
)
}
function Display({ value }) {
log('render Display', value)
return <p className="display">Count: {value}</p>
}
TodosPage.jsx has a controlled input and a keyed list, and subscribes to
resize in an effect:
import { useContext, useEffect, useState } from 'react'
import { ThemeContext } from './ThemeContext.js'
import { log } from './trace.js'
let nextId = 3
export function TodosPage() {
log('render TodosPage')
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn JSX' },
{ id: 2, text: 'Read about fibers' },
])
const [text, setText] = useState('')
useEffect(() => {
log('effect: subscribe to resize')
const onResize = () => log('window resized')
window.addEventListener('resize', onResize)
return () => {
log('cleanup: unsubscribe from resize')
window.removeEventListener('resize', onResize)
}
}, [])
function addTodo(event) {
event.preventDefault()
if (!text.trim()) return
setTodos([...todos, { id: nextId++, text }])
setText('')
}
return (
<section>
<h2>Todos</h2>
<form onSubmit={addTodo}>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="New todo" />
<button>Add</button>
</form>
<ul>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onRemove={() => setTodos(todos.filter((t) => t.id !== todo.id))}
/>
))}
</ul>
</section>
)
}
function TodoItem({ todo, onRemove }) {
log('render TodoItem', todo.id)
const theme = useContext(ThemeContext)
return (
<li className={theme}>
{todo.text} <button onClick={onRemove}>×</button>
</li>
)
}
ThemeContext.js and the log helper:
import { createContext } from 'react'
export const ThemeContext = createContext('light')
ThemeContext.displayName = 'ThemeContext'
// Open the app with ?trace to log every component call, effect and cleanup
// in the browser console, in the order React runs them.
const enabled = new URLSearchParams(location.search).has('trace')
export function log(...args) {
if (!enabled) return
console.log('%c[app]', 'color:#d9539f;font-weight:bold', ...args)
// scripts/trace.mjs collects these alongside React's own calls
globalThis.__TRACE__?.push(['app', args.join(' '), globalThis.__depth?.('log') ?? 0])
}
To follow along, run the demo (
examples/how-react-worksin the site’s repository:npm install && npm run dev) and openhttp://localhost:5199/?trace. Every component call, effect and cleanup is logged to the console in the order React runs them.
1. Compile time: JSX becomes function calls
Browsers don’t understand JSX. Before your code ever reaches the browser, a compiler rewrites each JSX tag into a function call that returns a plain object. This happens once, at build time. At run time there is no JSX left.
This is App after Babel’s React preset with the automatic runtime
(@babel/preset-react with runtime: 'automatic', the default since React 17):
export function App() {
log("render App");
const [page, setPage] = useState("counter");
const [theme, setTheme] = useState("light");
return /*#__PURE__*/_jsx(ThemeContext, {
value: theme,
children: /*#__PURE__*/_jsxs(Layout, {
children: [/*#__PURE__*/_jsx(Nav, {
page: page,
onNavigate: setPage
}), /*#__PURE__*/_jsxs("button", {
className: "theme",
onClick: () => setTheme(theme === "light" ? "dark" : "light"),
children: ["Theme: ", theme]
}), page === "counter" ? /*#__PURE__*/_jsx(CounterPage, {}) : /*#__PURE__*/_jsx(TodosPage, {})]
})
});
}
And the import Babel added at the top of the file, which you never wrote:
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
What changed, tag by tag:
<Nav page={page} onNavigate={setPage} />→_jsx(Nav, { page, onNavigate }). The first argument is the type: theNavfunction itself, not a string and not a call.<button className="theme" …>Theme: {theme}</button>→_jsxs("button", { …, children: ["Theme: ", theme] }). A lowercase tag becomes a string type. Children are no longer separate arguments: they move intoprops.children.jsxvsjsxs:jsxsis used when the children are a static array written in the JSX (several children side by side). One child, or none →jsx.keyis pulled out of props and passed as the third argument. InTodosPage:
export function TodosPage() {
log('render TodosPage');
const [todos, setTodos] = useState([{
id: 1,
text: 'Learn JSX'
}, {
id: 2,
text: 'Read about fibers'
}]);
const [text, setText] = useState('');
useEffect(() => {
log('effect: subscribe to resize');
const onResize = () => log('window resized');
window.addEventListener('resize', onResize);
return () => {
log('cleanup: unsubscribe from resize');
window.removeEventListener('resize', onResize);
};
}, []);
function addTodo(event) {
event.preventDefault();
if (!text.trim()) return;
setTodos([...todos, {
id: nextId++,
text
}]);
setText('');
}
return /*#__PURE__*/_jsxs("section", {
children: [/*#__PURE__*/_jsx("h2", {
children: "Todos"
}), /*#__PURE__*/_jsxs("form", {
onSubmit: addTodo,
children: [/*#__PURE__*/_jsx("input", {
value: text,
onChange: e => setText(e.target.value),
placeholder: "New todo"
}), /*#__PURE__*/_jsx("button", {
children: "Add"
})]
}), /*#__PURE__*/_jsx("ul", {
children: todos.map(todo => /*#__PURE__*/_jsx(TodoItem, {
todo: todo,
onRemove: () => setTodos(todos.filter(t => t.id !== todo.id))
}, todo.id))
})]
});
}
Notice _jsx("ul", { children: todos.map(...) }): the children are an
array, but it’s jsx, not jsxs, because the array is created at run time
by map. The compiler can’t know its length, so React has to check that each
item has a key (the “Each child in a list should have a unique key” warning
comes from here).
The /*#__PURE__*/ comments tell minifiers the call has no side effects, so
an unused element can be dropped.
The classic runtime: React.createElement
Before React 17, the same JSX compiled to React.createElement. Babel still
supports it with runtime: 'classic':
export function App() {
log("render App");
const [page, setPage] = useState("counter");
const [theme, setTheme] = useState("light");
return /*#__PURE__*/React.createElement(ThemeContext, {
value: theme
}, /*#__PURE__*/React.createElement(Layout, null, /*#__PURE__*/React.createElement(Nav, {
page: page,
onNavigate: setPage
}), /*#__PURE__*/React.createElement("button", {
className: "theme",
onClick: () => setTheme(theme === "light" ? "dark" : "light")
}, "Theme: ", theme), page === "counter" ? /*#__PURE__*/React.createElement(CounterPage, null) : /*#__PURE__*/React.createElement(TodosPage, null)));
}
The differences:
Classic: React.createElement |
Automatic: jsx / jsxs |
|
|---|---|---|
| Import | you had to write import React from 'react' in every file |
the compiler adds import { jsx } from 'react/jsx-runtime' |
| Children | extra arguments: createElement(type, props, child1, child2) |
inside props: jsx(type, { children: [child1, child2] }) |
key |
inside the props object; React had to copy props to remove it | a separate argument, so props can often be used as-is |
| Static vs dynamic children | indistinguishable | jsxs marks static arrays, so the key warning only fires for real lists |
Both produce the same kind of object. The automatic runtime just makes
the call cheaper and removes the React import requirement.
Under the hood: the real jsx() and createElement()
This is the whole production jsx from react/cjs/react-jsx-runtime.production.js
(19.2.5). jsx and jsxs are the same function in production; they
only differ in development, where jsxs skips key validation for static
arrays:
function jsxProd(type, config, maybeKey) {
var key = null;
void 0 !== maybeKey && (key = "" + maybeKey);
void 0 !== config.key && (key = "" + config.key);
if ("key" in config) {
maybeKey = {};
for (var propName in config)
"key" !== propName && (maybeKey[propName] = config[propName]);
} else maybeKey = config;
config = maybeKey.ref;
return {
$$typeof: REACT_ELEMENT_TYPE,
type: type,
key: key,
ref: void 0 !== config ? config : null,
props: maybeKey
};
}
exports.jsx = jsxProd;
exports.jsxs = jsxProd;
Three things to notice. The key is turned into a string ("" + maybeKey),
so key={1} becomes "1". When there’s no key in the config, the config
object itself becomes props: no copy. And ref is read from props: in
React 19, ref is a normal prop.
The classic createElement from react.production.js, for comparison. It
always copies props and collects children from arguments:
exports.createElement = function (type, config, children) {
var propName, props = {}, key = null;
if (null != config)
for (propName in (void 0 !== config.key && (key = "" + config.key), config))
hasOwnProperty.call(config, propName) && "key" !== propName &&
"__self" !== propName && "__source" !== propName &&
(props[propName] = config[propName]);
var childrenLength = arguments.length - 2;
if (1 === childrenLength) props.children = children;
else if (1 < childrenLength) {
for (var childArray = Array(childrenLength), i = 0; i < childrenLength; i++)
childArray[i] = arguments[i + 2];
props.children = childArray;
}
if (type && type.defaultProps) /* … copy defaultProps … */
return ReactElement(type, key, props);
};
What your browser actually receives in development
npm run dev doesn’t use Babel. Vite 8 compiles JSX with Oxc, and in
development it uses the jsxDEV variant, which also records where each
element came from (file, line, column) for warnings and React DevTools. This is
the App function exactly as Vite’s dev server sends it:
export function App() {
_s();
log("render App");
const [page, setPage] = useState("counter");
const [theme, setTheme] = useState("light");
return /* @__PURE__ */ _jsxDEV(ThemeContext, {
value: theme,
children: /* @__PURE__ */ _jsxDEV(Layout, { children: [
/* @__PURE__ */ _jsxDEV(Nav, {
page,
onNavigate: setPage
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 17,
columnNumber: 9
}, this),
/* @__PURE__ */ _jsxDEV("button", {
className: "theme",
onClick: () => setTheme(theme === "light" ? "dark" : "light"),
children: ["Theme: ", theme]
}, void 0, true, {
fileName: _jsxFileName,
lineNumber: 18,
columnNumber: 9
}, this),
page === "counter" ? /* @__PURE__ */ _jsxDEV(CounterPage, {}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 24,
columnNumber: 31
}, this) : /* @__PURE__ */ _jsxDEV(TodosPage, {}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 24,
columnNumber: 49
}, this)
] }, void 0, true, {
fileName: _jsxFileName,
lineNumber: 16,
columnNumber: 7
}, this)
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 15,
columnNumber: 5
}, this);
}
The extra _s(), $RefreshSig$ and $RefreshReg$ lines are Fast
Refresh bookkeeping (hot reloading that keeps state). Babel produces the
same jsxDEV calls with development: true. vite build produces the
production jsx/jsxs form shown above.
2. The element object {}
So <Display value={count} /> is just a call that returns an object. Here
is a real element, produced by React’s production runtime for
<p className="display">Count: {0}</p>:
{
$$typeof: "Symbol(react.transitional.element)",
type: "p",
key: null,
ref: null,
props: {
className: "display",
children: [
"Count: ",
0
]
}
}
And for <App />:
{
$$typeof: "Symbol(react.transitional.element)",
type: "[Function App]",
key: null,
ref: null,
props: {}
}
$$typeofmarks the object as a React element. It’s aSymbol, and JSON can’t contain symbols, so a server response or user input can never be mistaken for an element (an XSS defence).typeis either a string ("p": a DOM tag) or the component itself (App: a function). This one field is what React later uses to decide what to do.keyisnullor a string.jsx('li', {...}, 1)giveskey: "1".propsholds everything else, includingchildren.
Elements are cheap, immutable descriptions. In development React freezes
them (Object.freeze(element.props) and Object.freeze(element)). A new
element object is created every time the JSX runs, which happens every
time the component renders. They are never updated; they are thrown away.
Under the hood: the development element
The development runtime (jsxDEV, what the browser used in the trace) adds
debugging fields. Their names are real; nested objects are abbreviated:
{
$$typeof: "Symbol(react.transitional.element)",
type: "p",
key: null,
props: {
className: "display",
children: [
"Count: ",
0
]
},
_owner: null,
ref: null,
_store: "{…}",
_debugInfo: null,
_debugStack: "{…}",
_debugTask: "{…}"
}
_owner is the component that created the element (null here because the
sample was created outside a render). _debugStack and _debugTask are what
let React point at the line in your file in warnings and React DevTools.
_store.validated tracks the key warning.
When exactly are elements created? Only when the code containing the
jsx() call runs:
<App />inmain.jsxis created once, when the module runs.- Everything inside
App’sreturnis created each time React callsApp. childrenare created by the parent that wrote them.<Layout>’schildren(Nav, the theme button, the page) are created byApp, not byLayout.
jsx(Nav, { page, onNavigate: setPage })jsxs("button", { className, onClick, children: ["Theme: ", theme] })page === 'counter' ? jsx(CounterPage, {}) : jsx(TodosPage, {})jsxs(Layout, { children: [ … ] })jsx(ThemeContext, { value: theme, children: … })- render phase
App() → useState('counter'), useState('light')React callsApp. Its body runs top to bottom: twouseStatecalls, then thereturnexpression is evaluated.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App() - render phase
jsx(Nav, { page: "counter", onNavigate: setPage })JavaScript evaluates arguments before the call they belong to, so the innermost JSX runs first.jsx()returns a plain object.Navis not called: it’s just thetypefield.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App() › jsxDEV - render phase
jsxs("button", { …, children: ["Theme: ", "light"] })A host element:typeis the string"button". Two children →jsxswith an array.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App() › jsxDEV - render phase
jsx(CounterPage, {})The ternary picksCounterPage. TheTodosPageelement is never created on this render.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App() › jsxDEV - render phase
jsxs(Layout, { children: [navEl, buttonEl, pageEl] })The three finished objects becomeLayout’sprops.children. Layout will receive them; it does not create them.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App() › jsxDEV - render phase
jsx(ThemeContext, { value: "light", children: layoutEl })The outermost call runs last. Its object holds the whole description.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App() › jsxDEV - render phase
return { type: ThemeContext, … }Appreturns one object (with others nested inside). OnlyApphas run so far:Nav,LayoutandCounterPageare still just references, and React decides when to call them.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App()
3. The page opens
The browser loads index.html, which contains
<script type="module" src="/src/main.jsx">. Module scripts run after the
HTML is parsed. Here is everything that happens, in the order it happened in
the recorded trace:
- Imports run first.
react-dom/client,reactand the Scheduler evaluate their top-level code, which mostly defines functions. Then your modules run, which definesApp,Layout, … but calls none of them. createRoot(container)creates the FiberRoot (React’s bookkeeping for this root) and the very first fiber, HostRoot. It stores a pointer to that fiber on the#rootDOM node.listenToAllSupportedEvents(#root)attaches one listener per event type to#root, for both the capture and bubble phases. YouronClick={...}will never become a realaddEventListeneron a button.<App />is evaluated beforerenderis called (JavaScript evaluates arguments first):jsxDEV(App, {})returns{ type: App, props: {} }.root.render(element)asks for a lane: no event is in progress, so it’s DefaultLane (32). It creates an update with payload{ element }, queues it on the HostRoot fiber, and callsscheduleUpdateOnFiber.render()returns. It doesn’t render anything.ensureRootIsScheduledqueued a microtask, andmain.jsxfinishes with#rootstill empty.- The microtask (
processRootScheduleInMicrotask) sees a Default lane. That isn’t Sync, so it asks the Scheduler for a task. - The Scheduler task (
performWorkOnRootViaSchedulerTask) runsperformWorkOnRoot(root, 32)→renderRootSync. The render begins.
import { createRoot } from 'react-dom/client'import { App } from './App.jsx'const root = createRoot(document.getElementById('root'))root.render(jsxDEV(App, {}))- trigger
import 'react-dom/client'The browser loadsindex.html, which loadsmain.jsxas a module. Its imports run first: React, ReactDOM and the Scheduler are evaluated (their top-level code defines functions). Nothing renders.stack: main.jsx (module) - trigger
import { App } from './App.jsx'Your component modules run too, which only definesApp,Layout, … No component is called by importing it.stack: main.jsx (module) - trigger
createRoot(container) → createFiberRoot(…)React creates a FiberRoot (bookkeeping for this root) and the first fiber, HostRoot, then stores a pointer to it on the#rootDOM node.stack: main.jsx (module) › createRoot › createFiberRoot - trigger
listenToAllSupportedEvents(#root)One listener per event type (capture and bubble) is attached to#root. Later, every click inside the app reaches React through these, not through listeners on your buttons.stack: main.jsx (module) › createRoot › listenToAllSupportedEvents - trigger
jsxDEV(App, {})Arguments are evaluated first, so<App />becomes an element object.Appstill hasn’t run.stack: main.jsx (module) › jsxDEV - schedule
root.render(element) → requestUpdateLane(HostRoot) = DefaultLane (32)No event is happening, so the update gets the Default lane.stack: main.jsx (module) › root.render › requestUpdateLane - schedule
updateContainerImpl → enqueueUpdate({ element }) → scheduleUpdateOnFiber(HostRoot, 32)An update whose payload is{ element }is queued on the HostRoot fiber, and the root is marked as having work.stack: main.jsx (module) › root.render › updateContainerImpl › scheduleUpdateOnFiber - schedule
ensureRootIsScheduled → scheduleImmediateRootScheduleTask (microtask)render()returns without rendering.main.jsxfinishes;#rootis still empty.stack: main.jsx (module) › root.render › updateContainerImpl › scheduleUpdateOnFiber › ensureRootIsScheduled › scheduleImmediateRootScheduleTask - schedule
processRootScheduleInMicrotask → Scheduler taskThe microtask sees a Default lane (not Sync), so it asks the Scheduler for a task. The Scheduler posts it as a macrotask (viaMessageChannel).stack: processRootScheduleInMicrotask › scheduleCallback - render phase
performWorkOnRootViaSchedulerTask → performWorkOnRoot(root, 32) → renderRootSyncThe task runs and rendering starts. Default is a “blocking” lane, so it renders with the synchronous loop, without time slicing. The next chapter follows it.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync
Under the hood: the recorded trace, and the source
The real call order, up to the start of the render:
createRoot container #rootcreateFiberRootlistenToAllSupportedEventsjsxDEV Approot.render <App>requestUpdateLane HostRootupdateContainerImpl lane 32scheduleUpdateOnFiber HostRoot lane 32ensureRootIsScheduledscheduleImmediateRootScheduleTaskprocessRootScheduleInMicrotaskperformWorkOnRootViaSchedulerTaskperformWorkOnRoot lanes 32renderRootSyncprepareFreshStack
createRoot (React 19.2.5 development build, abbreviated):
exports.createRoot = function(container, options) {
if (!isValidContainer(container)) throw Error("Target container is not a DOM element.");
// … read options …
options = createFiberRoot(container, 1 /* ConcurrentRoot */, false, null, null, isStrictMode, …);
container[internalContainerInstanceKey] = options.current; // #root → HostRoot fiber
listenToAllSupportedEvents(container);
return new ReactDOMRoot(options);
};
root.render only queues:
ReactDOMRoot.prototype.render = function(children) {
var root = this._internalRoot;
var current = root.current;
updateContainerImpl(current, requestUpdateLane(current), children, root, null, null);
};
function updateContainerImpl(rootFiber, lane, element, container, parentComponent, callback) {
// …
container = createUpdate(lane);
container.payload = { element };
element = enqueueUpdate(rootFiber, container, lane);
null !== element && (scheduleUpdateOnFiber(element, rootFiber, lane), entangleTransitions(element, rootFiber, lane));
}
Why did a Default lane render with renderRootSync? Only transition,
retry and idle lanes are time-sliced; Sync, InputContinuous and Default are
“blocking” lanes rendered by the synchronous loop (see
Scheduler, Lanes and Batching).
4. The first render: how React decides what to call
React now walks the tree one fiber at a time: down with beginWork,
across to siblings, up with completeWork (The Work Loop). There’s no
fiber tree yet, so each step creates the fibers for the children it
finds.
The question “how does React know whether to call something?” has a short
answer: the element’s type. When React creates a fiber from an
element it picks a tag:
element.type |
fiber tag | What beginWork does with it |
|---|---|---|
a function (App, Nav, …) |
0 FunctionComponent |
calls it: renderWithHooks → Component(props) |
a string ("div", "button") |
5 HostComponent |
doesn’t call anything; reconciles props.children. completeWork creates the DOM node |
a context (ThemeContext) |
10 ContextProvider |
pushes value for the components below; reconciles children |
| a string or number child | 6 HostText |
creates a text node |
| the root | 3 HostRoot |
renders the element from its update queue |
So a component is called when the work loop reaches its fiber, and only then. The order is depth-first: a component is called before its children, and a child is called before the parent’s next sibling.
- render phase
beginWork(HostRoot) → reconcileChildFibers(HostRoot, <App>)Render starts at the HostRoot fiber. Its pending update says: render<App />.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(HostRoot) › reconcileChildren › reconcileChildFibers - render phase
createFiberFromTypeAndProps(App) → tag 0React needs a fiber for theAppelement.typeof App === "function"→ tag 0 (FunctionComponent).stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(HostRoot) › reconcileChildren › reconcileChildFibers › reconcileSingleElement › createFiberFromTypeAndProps - render phase
beginWork(App) → renderWithHooks(App) → App(props)NowAppis called for the first time. ItsuseStates are created, and itsjsx()calls create 5 elements.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App() › mountState - render phase
createFiberFromTypeAndProps(ThemeContext) → tag 10The returned<ThemeContext>element’s type is a context object ($$typeof: react.context) → tag 10 (context provider).stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › reconcileChildren › reconcileChildFibers › reconcileSingleElement › createFiberFromTypeAndProps - render phase
beginWork(ThemeContext) → pushProvider · createFiber(Layout)Providers aren’t called. React pushesvalue: "light"on its context stack so components below can read it, then reconciles its child.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(ThemeContext) › reconcileChildren › reconcileChildFibers › reconcileSingleElement › createFiberFromTypeAndProps - render phase
beginWork(Layout) → renderWithHooks → Layout({ children })Layoutis called withprops.children: the elements App already created. Layout creates onlydiv,h1,main.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Layout) › updateFunctionComponent › renderWithHooks › Layout() - render phase
beginWork(div) → reconcileChildrenArray(div, [h1, main])A string type → tag 5 (HostComponent). Host fibers aren’t called; React reconciles theirprops.children.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(div) › reconcileChildren › reconcileChildFibers › reconcileChildrenArray › createFiberFromTypeAndProps - render phase
beginWork(h1) → completeWork(h1)h1has a single text child, set directly as text content (no text fiber). No children → it completes immediately:document.createElement("h1").stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(h1) - render phase
beginWork(main) → reconcileChildrenArray(main, [Nav, button, CounterPage])main’s children are the three elements App created:Nav, the themebutton,CounterPage. Fibers for all three are created first (siblings), then React goes down into the first.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(main) › reconcileChildren › reconcileChildFibers › reconcileChildrenArray › createFiberFromTypeAndProps - render phase
Nav({ page, onNavigate }) → … completeWork(button, button, nav, Nav)Navis called and returns a<nav>with two buttons. They complete, thenNavcompletes: go across to the sibling.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Nav) › updateFunctionComponent › renderWithHooks › Nav() - render phase
beginWork(button) → createFiberFromText ×2 → completeWorkThe theme button has two text children ("Theme: ","light"), so it gets two text fibers. They complete, then the button does.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(button) - render phase
beginWork(CounterPage) → CounterPage()CounterPageis called:useState(0),useRef,useLayoutEffectanduseEffectare registered (effects only recorded, not run).stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(CounterPage) › updateFunctionComponent › renderWithHooks › CounterPage() › mountState - render phase
beginWork(section) → reconcileChildrenArray(section, [h2, Display, button])section’s three children get fibers.h2completes right away.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(section) › reconcileChildren › reconcileChildFibers › reconcileChildrenArray › createFiberFromTypeAndProps - render phase
beginWork(Display) → Display({ value: 0 })Displayis called with{ value: 0 }and returns<p>Count: {value}</p>.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Display) › updateFunctionComponent › renderWithHooks › Display() - render phase
beginWork(p) → … → completeWork(p) · completeWork(Display)Thepgets two text fibers, completes, andDisplaycompletes.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(p) - render phase
beginWork(button) → completeWork(button)The last leaf: the “Add one” button.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(button) - render phase
completeWork(section, CounterPage, main, div, Layout, ThemeContext, App, HostRoot)No more siblings: React climbs to the root, completing each parent.workInProgressbecomesnull. The render is finished, but nothing is on screen yet.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(HostRoot)
beginWork, across, and up with completeWork. Orange = a component being called.In order, the first render called: App → Layout → Nav →
CounterPage → Display. ThemeContext and every DOM tag were never
“called”; they’re not functions.
Each component creates its own output only when it’s called. App’s
jsx() calls run inside renderWithHooks(App). Nav’s run later, inside
renderWithHooks(Nav). That’s why in the trace the jsxDEV lines appear
right after each [app] render … line.
Under the hood: how the fiber tag is chosen, and how a component gets called
createFiberFromTypeAndProps (abbreviated) is where an element’s type
becomes a fiber tag:
function createFiberFromTypeAndProps(type, key, pendingProps, owner, mode, lanes) {
var fiberTag = 0 /* FunctionComponent */, resolvedType = type;
if ("function" === typeof type) shouldConstruct(type) && (fiberTag = 1 /* ClassComponent */);
else if ("string" === typeof type) fiberTag = /* hoistable / singleton checks */ … 5 /* HostComponent */;
else switch (type) {
case REACT_FRAGMENT_TYPE: return createFiberFromFragment(pendingProps.children, mode, lanes, key);
case REACT_SUSPENSE_TYPE: /* … tag 13 … */
default:
if ("object" === typeof type && null !== type) switch (type.$$typeof) {
case REACT_CONTEXT_TYPE: fiberTag = 10; break; // <ThemeContext value>
case REACT_CONSUMER_TYPE: fiberTag = 9; break;
case REACT_FORWARD_REF_TYPE: fiberTag = 11; break;
case REACT_MEMO_TYPE: fiberTag = 14; break;
// …
}
}
// … createFiber(fiberTag, pendingProps, key, mode), set type …
}
beginWork then switches on that tag. The two cases this app uses most:
switch (workInProgress.tag) {
case 0: return updateFunctionComponent(current, workInProgress, workInProgress.type, workInProgress.pendingProps, renderLanes);
case 5: /* HostComponent: reconcile props.children (inlined in the build) */ …
// case 10: pushProvider(workInProgress, context, value); reconcileChildren(…)
// …
}
updateFunctionComponent calls renderWithHooks, which installs the right
set of hook implementations (mount or update) and then calls your function:
function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {
renderLanes = nextRenderLanes;
currentlyRenderingFiber = workInProgress;
workInProgress.memoizedState = null;
workInProgress.updateQueue = null;
workInProgress.lanes = 0;
ReactSharedInternals.H = null !== current && null !== current.memoizedState
? HooksDispatcherOnUpdateInDEV
: HooksDispatcherOnMountInDEV; // ← useState means "mountState" on the first render
var children = callComponentInDEV(Component, props, secondArg); // ← App(props)
// …
return children; // the element objects App returned
}
The start of the recorded first render, until React enters the context
provider. Note the order of the jsxDEV calls inside App: innermost
first, because JavaScript evaluates arguments before the call:
beginWork HostRootreconcileChildFibersImpl HostRoot ← <App>createFiberFromTypeAndProps AppbeginWork ApprenderWithHooks App (mount)[app] render AppmountState "counter"mountState "light"jsxDEV NavjsxDEV buttonjsxDEV CounterPagejsxDEV LayoutjsxDEV ThemeContextreconcileChildFibersImpl App ← <ThemeContext>createFiberFromTypeAndProps ThemeContextbeginWork ThemeContext
Layout creates only its own tags. Its children arrived ready-made:
beginWork LayoutrenderWithHooks Layout (mount)[app] render LayoutjsxDEV h1jsxDEV mainjsxDEV divreconcileChildFibersImpl Layout ← <div>createFiberFromTypeAndProps divbeginWork divreconcileChildFibersImpl div ← array[2]reconcileChildrenArray div ← [2 children]createFiberFromTypeAndProps h1placeChild h1 (new) lastPlacedIndex 0createFiberFromTypeAndProps mainplaceChild main (new) lastPlacedIndex 0beginWork h1completeWork h1beginWork mainreconcileChildFibersImpl main ← array[3]reconcileChildrenArray main ← [3 children]
The fiber tree when the first render finished (recorded from the live page).
hooks lists each hook’s stored value in order:
HostRoot (tag 3)
App (tag 0) hooks: ["counter", "light"]
ThemeContext (tag 10)
Layout (tag 0) hooks: []
div (tag 5) → <div>
h1 (tag 5) → <h1>
main (tag 5) → <main>
Nav (tag 0) hooks: []
nav (tag 5) → <nav>
button (tag 5) → <button>
button (tag 5) → <button>
button (tag 5) → <button>
text "Theme: " (tag 6) → <#text>
text "light" (tag 6) → <#text>
CounterPage (tag 0) hooks: [0, ref, effect, effect]
section (tag 5) → <section>
h2 (tag 5) → <h2>
Display (tag 0) hooks: []
p (tag 5) → <p>
text "Count: " (tag 6) → <#text>
text "0" (tag 6) → <#text>
button (tag 5) → <button>
Note Layout’s empty hook list: useContext reads from the context stack
and doesn’t create a hook object (see Hooks Under the Hood). And h1 has
no text fiber: a single text child is set directly as the element’s text
content.
5. Commit and the first paint
During the render, every completeWork on a host fiber created its DOM
node and appended its children’s DOM nodes to it. By the time the render
reaches the root, the whole UI exists as one detached <div>. The
document hasn’t been touched.
Then the commit runs in one synchronous pass (Commit Phase and Effects):
- Mutation. The
Appfiber carries thePlacementflag. It isn’t a DOM node, so React descends (App → ThemeContext → Layout) until it finds one, and does oneappendChild(<div>)into#root. - Swap.
root.current = finishedWork: the new tree is now the current one. - Layout effects.
useLayoutEffectruns. The DOM exists and can be measured, but nothing has been painted. - Paint. The Scheduler task ends; the browser paints.
- Passive effects. Because this render used the Default lane,
useEffectruns in a later task, after the paint.
- render phase
completeWork(h1) → document.createElement("h1")DOM nodes are created during the render phase, on the way up, completely detached from the page.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(h1) - render phase
completeWork(nav) → createElement + appendAllChildrenEach completed host fiber appends its children’s DOM nodes to its own node, which builds the DOM tree bottom-up.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(nav) - render phase
completeWork(section) → appendAllChildren(h2, p, button)Displayis a component, not a DOM node, so React reaches through it and appends its<p>.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(section) - render phase
completeWork(main) · completeWork(div)At the top host fiber the whole UI exists as one detached<div>. Still nothing on screen.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(div) - commit phase
commitRoot → flushMutationEffects → commitPlacement(App)Commit starts.Appis the fiber flagged Placement. It isn’t a DOM node, so React descends: App → ThemeContext → Layout →div.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitPlacement - commit phase
insertOrAppendPlacementNodeIntoContainer → appendChild(<div>) into #rootOne DOM insertion puts the whole tree into the document. Thenroot.current = finishedWork.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitPlacement › insertOrAppendPlacementNode - commit phase
flushLayoutEffects → commitHookEffectListMount(CounterPage, Layout)Layout effects run now: the DOM exists and can be measured, but the browser hasn’t painted.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › commitRoot › flushLayoutEffects › commitLayoutEffectOnFiber › commitHookEffectListMount - browser
task ends → style · layout · paintThe Scheduler task returns and the browser paints: the user sees the app.stack: - after paint
flushPassiveEffects → commitHookEffectListMount(CounterPage, Passive)This render used the Default lane, souseEffectruns in a later task, after paint. (For a click it runs at the end of the commit: see chapter 5.)stack: (Scheduler task) › flushPassiveEffects › commitHookEffectListMount
Under the hood: the recorded commit
commitRootcommitBeforeMutationEffectsflushMutationEffectscommitPlacement AppinsertOrAppendPlacementNodeIntoContainer App is not a DOM node → descend to its childinsertOrAppendPlacementNodeIntoContainer ThemeContext is not a DOM node → descend to its childinsertOrAppendPlacementNodeIntoContainer Layout is not a DOM node → descend to its childinsertOrAppendPlacementNodeIntoContainer appendChild(<div>) into #rootflushLayoutEffectscommitHookEffectListMount CounterPage HasEffect|Layout[app] layout effect: button is 88 px wideensureRootIsScheduledscheduleImmediateRootScheduleTaskensureRootIsScheduledprocessRootScheduleInMicrotaskflushPassiveEffectscommitHookEffectListMount CounterPage HasEffect|Passive[app] effect: set title 0
insertOrAppendPlacementNodeIntoContainer checks the fiber’s tag: for a
host component (5) or text (6) it calls appendChild/insertBefore on the
real node; otherwise it recurses into the fiber’s child. That’s how a
component fiber “inserts” the DOM nodes it renders.
6. A click: state update and re-render
The user clicks “Add one”. Here is everything that happens:
- The event. The browser dispatches
click. React’s listener on#rootruns (dispatchDiscreteEvent, once for the capture phase and once for bubble), finds the fiber of the clicked button, collects theonClickprops on the path, and calls yours inside aSyntheticEvent. setCount(1)callsdispatchSetState(CounterPage fiber, queue, 1). A click is a discrete event → SyncLane (2). The update is queued on the hook and a microtask is scheduled. Your handler returns. Nothing has re-rendered yet.- Render, starting from the root. React marks the path:
CounterPage.lanes, andchildLaneson every ancestor. Then the work loop starts at HostRoot again. Ancestors with onlychildLanesbail out: their functions are not called. Siblings with no work (h1,Nav, the theme button) are skipped with their entire subtree. CounterPageis called because it has the update.useStatenow returns1. Itsjsx()calls create brand-new element objects, the same way as on the first render.- Reconciliation. The new elements are compared with the existing
fibers: same types at the same positions, so the fibers are reused
(
useFiber) with new props.Displayis called (its props object is new), and its text child changes from"0"to"1". - Commit. One DOM write: the text node becomes
"1". The layout effect reruns (its deps changed). Because this was a SyncLane render,useEffectis flushed at the end of the commit: first the old effect’s cleanup (withcount0), then the new effect. - Paint. The microtask ends and the browser paints “Count: 1”.
- event
dispatchDiscreteEvent("click") · capture, then bubble listener on #rootThe click lands on the “Add one” button, but the listener is on#root. React finds the button’s fiber, collectsonClickprops along the path, and calls yours.stack: dispatchDiscreteEvent › dispatchEvent › onClick - event
setCount(1) → dispatchSetState(CounterPage, 1)Your handler callssetCount(1). An update is queued onCounterPage’s state hook. Nothing renders yet.stack: dispatchDiscreteEvent › dispatchEvent › onClick › dispatchSetState - schedule
scheduleUpdateOnFiber(CounterPage, SyncLane) → ensureRootIsScheduledA click is a discrete event → SyncLane. React schedules a microtask; the event handler returns first.stack: dispatchDiscreteEvent › dispatchEvent › onClick › dispatchSetState › scheduleUpdateOnFiber › ensureRootIsScheduled - render phase
processRootScheduleInMicrotask → performSyncWorkOnRoot → markUpdateLaneFromFiberToRootThe microtask renders. First React marks the path: CounterPage getslanes, every ancestor getschildLanes.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › prepareFreshStack › finishQueueingConcurrentUpdates › markUpdateLaneFromFiberToRoot - render phase
beginWork(HostRoot … main) → bailoutOnAlreadyFinishedWork → children have workEach ancestor has no update of its own, so it bails out, and its function isn’t called.App()doesn’t run.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(main) › bailoutOnAlreadyFinishedWork - render phase
beginWork(h1 · Nav · button) → bailout → skip subtreeSiblings with no pending work are skipped with their whole subtree.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Nav) › bailoutOnAlreadyFinishedWork - render phase
renderWithHooks(CounterPage) → CounterPage() → updateReducer → count = 1CounterPagehas the update → it’s called.useStatereturns 1, and new element objects are created for its whole output.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(CounterPage) › updateFunctionComponent › renderWithHooks › CounterPage() › updateReducer - render phase
reconcileChildrenArray(section) → useFiber(h2, Display, button)Same types at the same positions → the existing fibers are reused with new props.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(section) › reconcileChildren › reconcileChildFibers › reconcileChildrenArray › useFiber - render phase
Display({ value: 1 }) → text "0" → "1"Displayreceives a new props object, so it re-renders. Its text child changed → flagged Update.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Display) › updateFunctionComponent › renderWithHooks › Display() - render phase
completeWork(… → HostRoot)React climbs back to the root. OnlyCounterPageandDisplayran.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(HostRoot) - commit phase
flushMutationEffects → commitTextUpdate("0" → "1")One text write. (commitUpdateis also called for the re-rendered host nodes, but it finds no changed props.)stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitTextUpdate - commit phase
flushLayoutEffects → layout effect (button width)The layout effect’s deps[count]changed, so it runs again, still before paint.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushLayoutEffects › commitLayoutEffectOnFiber › commitHookEffectListMount - after paint
flushPassiveEffects (SyncLane: at the end of the commit)For a click, React flushesuseEffectright away: first the previous effect’s cleanup (with the oldcount, 0)…stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushPassiveEffects › commitHookEffectListUnmount - after paint
commitHookEffectListMount(CounterPage, Passive)…then the new effect withcount = 1.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushPassiveEffects › commitHookEffectListMount - browser
microtask ends → paintThe browser paints “Count: 1”.stack:
Elements vs fibers on this click: every element under CounterPage was
recreated (they always are), but no fiber was created. Every fiber was
the old one, updated in place through its alternate (React Fiber).
Under the hood: the recorded click
dispatchDiscreteEvent click (capture listener)dispatchDiscreteEvent click (bubble listener)dispatchSetState CounterPage ← 1requestUpdateLane CounterPagescheduleUpdateOnFiber CounterPage lane 2ensureRootIsScheduledscheduleImmediateRootScheduleTaskprocessRootScheduleInMicrotaskperformSyncWorkOnRootperformWorkOnRoot lanes 2renderRootSyncprepareFreshStackmarkUpdateLaneFromFiberToRoot CounterPagebeginWork HostRootbailoutOnAlreadyFinishedWork HostRoot → children have workbeginWork AppbailoutOnAlreadyFinishedWork App → children have workbeginWork ThemeContextbailoutOnAlreadyFinishedWork ThemeContext → children have workbeginWork LayoutbailoutOnAlreadyFinishedWork Layout → children have workbeginWork divbailoutOnAlreadyFinishedWork div → children have workbeginWork h1bailoutOnAlreadyFinishedWork h1 → skip subtreecompleteWork h1beginWork mainbailoutOnAlreadyFinishedWork main → children have workbeginWork NavbailoutOnAlreadyFinishedWork Nav → skip subtreecompleteWork NavbeginWork buttonbailoutOnAlreadyFinishedWork button → skip subtreecompleteWork buttonbeginWork CounterPagerenderWithHooks CounterPage (update)[app] render CounterPageupdateReducerjsxDEV h2jsxDEV DisplayjsxDEV buttonjsxDEV sectionreconcileChildFibersImpl CounterPage ← <section>useFiber sectionbeginWork sectionreconcileChildFibersImpl section ← array[3]reconcileChildrenArray section ← [3 children]useFiber h2placeChild h2 oldIndex 0 lastPlacedIndex 0useFiber DisplayplaceChild Display oldIndex 1 lastPlacedIndex 0useFiber buttonplaceChild button oldIndex 2 lastPlacedIndex 1beginWork h2completeWork h2beginWork DisplayrenderWithHooks Display (update)[app] render Display 1jsxDEV preconcileChildFibersImpl Display ← <p>useFiber pbeginWork preconcileChildFibersImpl p ← array[2]reconcileChildrenArray p ← [2 children]useFiber text "Count: "placeChild text "Count: " oldIndex 0 lastPlacedIndex 0useFiber text "0"placeChild text "1" oldIndex 1 lastPlacedIndex 0beginWork text "Count: "bailoutOnAlreadyFinishedWork text "Count: " → skip subtreecompleteWork text "Count: "beginWork text "1"completeWork text "1"completeWork pcompleteWork DisplaybeginWork buttoncompleteWork buttoncompleteWork sectioncompleteWork CounterPagecompleteWork maincompleteWork divcompleteWork LayoutcompleteWork ThemeContextcompleteWork AppcompleteWork HostRootcommitRootcommitBeforeMutationEffectsflushMutationEffectscommitUpdate h2commitTextUpdate "0" → "1"commitUpdate pcommitUpdate buttoncommitUpdate sectionflushLayoutEffectscommitHookEffectListMount CounterPage HasEffect|Layout[app] layout effect: button is 88 px wideflushPassiveEffectscommitHookEffectListUnmount CounterPage HasEffect|Passive[app] cleanup: title effect 0commitHookEffectListMount CounterPage HasEffect|Passive[app] effect: set title 1
Two details in this trace. First, commitUpdate is called for
h2, p, button and section, even though only a text changed. In
React 19 a host fiber with a new props object is flagged in
completeWork, and the actual comparison of attributes happens in the
commit (commitUpdate diffs old and new props and writes only what
changed, here nothing). Second, bailoutOnAlreadyFinishedWork text "Count: ":
even text fibers bail out when their content is unchanged.
7. A context change
The theme button calls setTheme('dark'). The state lives in App, so
this time App re-renders. Because App creates new elements for
everything below it, every component under it re-renders too: Layout,
Nav, CounterPage, Display. None of them is wrapped in memo, and
each receives a new props object.
Context propagation only becomes visible when something would bail out:
React then searches the subtree for fibers that read the changed context
(propagateContextChanges) and schedules them, even through a memo
boundary. In this app both Layout and every TodoItem read ThemeContext,
so after a theme change they’d re-render even if their parents were
memoized.
Under the hood: the recorded theme change
dispatchSetState App ← "dark"requestUpdateLane AppscheduleUpdateOnFiber App lane 2ensureRootIsScheduledscheduleImmediateRootScheduleTaskprocessRootScheduleInMicrotaskperformSyncWorkOnRootperformWorkOnRoot lanes 2renderRootSyncprepareFreshStackmarkUpdateLaneFromFiberToRoot AppbeginWork HostRootbailoutOnAlreadyFinishedWork HostRoot → children have workbeginWork ApprenderWithHooks App (update)[app] render AppupdateReducerupdateReducerjsxDEV NavjsxDEV buttonjsxDEV CounterPagejsxDEV LayoutjsxDEV ThemeContextreconcileChildFibersImpl App ← <ThemeContext>useFiber ThemeContextbeginWork ThemeContextreconcileChildFibersImpl ThemeContext ← <Layout>useFiber LayoutbeginWork LayoutrenderWithHooks Layout (update)[app] render Layout
And the commit: commitTextUpdate "light" → "dark" is the only text that
changed, and commitUpdate div applies the new className (“layout dark”):
commitRootcommitBeforeMutationEffectsflushMutationEffectscommitUpdate h1commitUpdate buttoncommitUpdate buttoncommitUpdate navcommitTextUpdate "light" → "dark"commitUpdate buttoncommitUpdate h2commitUpdate pcommitUpdate buttoncommitUpdate sectioncommitUpdate maincommitUpdate divflushLayoutEffectsflushPassiveEffects
No effects ran: CounterPage’s effects depend on [count], which didn’t
change.
8. The list: keys at work
On the Todos page, todos.map(todo => <TodoItem key={todo.id} … />)
produces an array of elements, and each one carries a key (compiled to the
third argument of jsx). React matches old and new children by key
(Child Reconciliation Algorithm).
Typing in the input. Every keystroke calls setText, re-renders
TodosPage, and with it both TodoItems, because they get new props
objects (a new onRemove function every render). The DOM writes are tiny,
but the components run:
dispatchDiscreteEvent input (capture listener)dispatchDiscreteEvent input (bubble listener)dispatchSetState TodosPage ← "x"requestUpdateLane TodosPagescheduleUpdateOnFiber TodosPage lane 2ensureRootIsScheduledscheduleImmediateRootScheduleTaskperformSyncWorkOnRootperformWorkOnRoot lanes 2renderRootSyncprepareFreshStackmarkUpdateLaneFromFiberToRoot TodosPagebeginWork HostRootbailoutOnAlreadyFinishedWork HostRoot → children have workbeginWork AppbailoutOnAlreadyFinishedWork App → children have workbeginWork ThemeContextbailoutOnAlreadyFinishedWork ThemeContext → children have workbeginWork LayoutbailoutOnAlreadyFinishedWork Layout → children have workbeginWork divbailoutOnAlreadyFinishedWork div → children have workbeginWork h1bailoutOnAlreadyFinishedWork h1 → skip subtreecompleteWork h1beginWork mainbailoutOnAlreadyFinishedWork main → children have workbeginWork NavbailoutOnAlreadyFinishedWork Nav → skip subtreecompleteWork NavbeginWork buttonbailoutOnAlreadyFinishedWork button → skip subtreecompleteWork buttonbeginWork TodosPagerenderWithHooks TodosPage (update)[app] render TodosPageupdateReducerupdateReducerjsxDEV h2jsxDEV inputjsxDEV buttonjsxDEV formjsxDEV TodoItem key=1jsxDEV TodoItem key=2jsxDEV uljsxDEV sectionreconcileChildFibersImpl TodosPage ← <section>useFiber sectionbeginWork sectionreconcileChildFibersImpl section ← array[3]reconcileChildrenArray section ← [3 children]useFiber h2placeChild h2 oldIndex 0 lastPlacedIndex 0useFiber formplaceChild form oldIndex 1 lastPlacedIndex 0useFiber ulplaceChild ul oldIndex 2 lastPlacedIndex 1beginWork h2completeWork h2beginWork formreconcileChildFibersImpl form ← array[2]reconcileChildrenArray form ← [2 children]useFiber inputplaceChild input oldIndex 0 lastPlacedIndex 0useFiber buttonplaceChild button oldIndex 1 lastPlacedIndex 0beginWork inputcompleteWork inputbeginWork buttoncompleteWork buttoncompleteWork formbeginWork ulreconcileChildFibersImpl ul ← array[2]reconcileChildrenArray ul ← [2 children]useFiber TodoItem[key=1]placeChild TodoItem[key=1] oldIndex 0 lastPlacedIndex 0useFiber TodoItem[key=2]placeChild TodoItem[key=2] oldIndex 1 lastPlacedIndex 0beginWork TodoItem[key=1]renderWithHooks TodoItem[key=1] (update)[app] render TodoItem 1jsxDEV buttonjsxDEV lireconcileChildFibersImpl TodoItem[key=1] ← <li>useFiber libeginWork lireconcileChildFibersImpl li ← array[3]reconcileChildrenArray li ← [3 children]useFiber text "Learn JSX"placeChild text "Learn JSX" oldIndex 0 lastPlacedIndex 0useFiber text " "placeChild text " " oldIndex 1 lastPlacedIndex 0useFiber buttonplaceChild button oldIndex 2 lastPlacedIndex 1beginWork text "Learn JSX"bailoutOnAlreadyFinishedWork text "Learn JSX" → skip subtreecompleteWork text "Learn JSX"beginWork text " "bailoutOnAlreadyFinishedWork text " " → skip subtreecompleteWork text " "beginWork buttoncompleteWork buttoncompleteWork licompleteWork TodoItem[key=1]beginWork TodoItem[key=2]renderWithHooks TodoItem[key=2] (update)[app] render TodoItem 2
Adding a todo. The submit handler calls setTodos and setText.
Two updates, one render: both are queued during the event and the single
microtask processes them together (batching). The keyed diff reuses keys 1
and 2 and creates key 3:
dispatchSetState TodosPage ← [{"id":1,"text":"Learn JSX"},{"id":2,"text":"Read about fiberequestUpdateLane TodosPagescheduleUpdateOnFiber TodosPage lane 2ensureRootIsScheduledscheduleImmediateRootScheduleTaskdispatchSetState TodosPage ← ""requestUpdateLane TodosPagescheduleUpdateOnFiber TodosPage lane 2ensureRootIsScheduledprocessRootScheduleInMicrotaskperformSyncWorkOnRootperformWorkOnRoot lanes 2renderRootSync
reconcileChildrenArray ul ← [3 children]useFiber TodoItem[key=1]placeChild TodoItem[key=1] oldIndex 0 lastPlacedIndex 0useFiber TodoItem[key=2]placeChild TodoItem[key=2] oldIndex 1 lastPlacedIndex 0createFiberFromTypeAndProps TodoItemplaceChild TodoItem[key=3] (new) lastPlacedIndex 1
Removing the first todo. The first keys differ, so React builds a
Map of the old children, reuses keys 2 and 3, and deletes key 1. That’s
one removeChild:
reconcileChildrenArray ul ← [2 children]mapRemainingChildrenuseFiber TodoItem[key=2]placeChild TodoItem[key=2] oldIndex 1 lastPlacedIndex 0useFiber TodoItem[key=3]placeChild TodoItem[key=3] oldIndex 2 lastPlacedIndex 1deleteChild TodoItem[key=1]
commitDeletionEffectsOnFiber TodoItem[key=1]commitDeletionEffectsOnFiber licommitDeletionEffectsOnFiber text "Learn JSX"commitDeletionEffectsOnFiber text " "commitDeletionEffectsOnFiber buttonremoveChild <li> from <ul>
- event
submit → setTodos([...]) · setText("")Two state updates in one handler. The trace shows one render: both were queued and the microtask processed them together (batching). - render phase
TodosPage() → todos.map → jsx(TodoItem, {…}, 1 / 2 / 3)TodosPage re-renders and creates threeTodoItemelements. The key is the third argument ofjsx(). - render phase
reconcileChildrenArray(ul) · updateSlot(key 1, key 1) · placeChildPass 1 walks both lists while keys match. Key 1 = key 1 → reuse. - render phase
updateSlot(key 2, key 2) · placeChildKey 2 = key 2 → reuse.lastPlacedIndexbecomes 1. - render phase
createFiberFromTypeAndProps(TodoItem) · placeChild → PlacementThe old list ran out. Key 3 is a new fiber, flagged Placement. TodoItem 3 mounts; items 1 and 2 re-render (their parent did) but keep their fibers. - commit phase
commitPlacement(TodoItem[key=3]) → appendChild(<li>) into <ul>One DOM insertion.
- event
onRemove → setTodos(todos.filter(t => t.id !== 1))TheonRemoveclosure was created by TodosPage for this item. - render phase
reconcileChildrenArray(ul) · updateSlot(old key 1, new key 2) → nullPass 1: the first keys differ, so the fast path stops immediately. - render phase
mapRemainingChildren(oldFiber)All old fibers go into a Map by key. - render phase
updateFromMap('2') → useFiber · placeChild: oldIndex 1 ≥ 0Key 2 found → reused, it stays.lastPlacedIndex= 1. - render phase
updateFromMap('3') → useFiber · placeChild: oldIndex 2 ≥ 1Key 3 found → reused, stays. - render phase
deleteChild(TodoItem[key=1])Key 1 is left in the map → deleted. - commit phase
commitDeletionEffectsOnFiber(TodoItem[key=1]) → removeChild(<li>) from <ul>One DOM operation. The other two<li>s are untouched. Without keys, React would have rewritten the text of rows 1 and 2 and removed the last row.
<li>; removing deletes exactly one.9. A page switch: unmount and mount
“Todos” calls setPage('todos'). App re-renders and, in the third slot of
Layout’s children, returns <TodosPage /> where there used to be
<CounterPage />. Same position, different type: React doesn’t compare
inside. It deletes the whole CounterPage subtree (with count) and mounts
TodosPage from scratch (Reconciliation, Rule 1).
The order of effects is the part people get wrong:
- Render:
TodosPageand its items are called and their DOM is built off-screen.CounterPageis only marked for deletion. - Commit, mutation: React walks the deleted subtree
(
commitDeletionEffectsOnFiber), then removes its top DOM node with oneremoveChild(<section>), and inserts the new<section>with oneappendChild. - Passive effects: first the unmount cleanups (
cleanup: title effect), then the new effects (effect: subscribe to resize).
- event
onNavigate('todos') → dispatchSetState(App, 'todos')The “Todos” button callsonNavigate, which isApp’ssetPage. The update goes on App, which owns the state.stack: dispatchDiscreteEvent › dispatchEvent › onClick › dispatchSetState - render phase
App() → page === "todos" → jsx(TodosPage, {})App re-renders. This time the ternary creates aTodosPageelement in the third slot ofLayout’s children.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App() › jsxDEV - render phase
Layout() · Nav({ page: "todos" })Both re-render: App created new elements for them, so their props are new objects.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Nav) › updateFunctionComponent › renderWithHooks › Nav() - render phase
reconcileChildrenArray(main, [Nav, button, TodosPage])Slots 0 and 1 match. Slot 2: element typeTodosPagevs fiber typeCounterPage→ different type.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(main) › reconcileChildren › reconcileChildFibers › reconcileChildrenArray - render phase
createFiberFromTypeAndProps(TodosPage) · deleteChild(CounterPage)A new fiber for TodosPage (flagged Placement), and CounterPage is added tomain.deletions(main flagged ChildDeletion).count: 1will be lost.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(main) › reconcileChildren › reconcileChildFibers › reconcileChildrenArray › deleteChild - render phase
TodosPage() → mountState([…]), mountState(''), useEffectTodosPage mounts: fresh state. Itstodos.mapcreates twoTodoItemelements with keys 1 and 2.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(TodosPage) › updateFunctionComponent › renderWithHooks › TodosPage() › mountState - render phase
TodoItem[key=1]() · TodoItem[key=2]() · completeWork(…)Both items mount, and their DOM is built off-screen as React climbs.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(TodoItem) › updateFunctionComponent › renderWithHooks › TodoItem() - commit phase
commitDeletionEffectsOnFiber(CounterPage → section → …)Mutation phase: React walks the deleted subtree (detaching refs, running layout cleanups)…stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitDeletionEffects - commit phase
removeChild(<section>) from <main>…then removes only the top DOM node of that subtree.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitDeletionEffects › removeChild - commit phase
commitPlacement(TodosPage) → appendChild(<section>) into <main>The new subtree goes in with one insertion.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitPlacement › insertOrAppendPlacementNode - after paint
flushPassiveEffects → commitHookEffectListUnmount(CounterPage, Passive)Passive effects: unmount cleanups first. The deleted CounterPage’s title effect cleans up…stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushPassiveEffects › commitHookEffectListUnmount - after paint
commitHookEffectListMount(TodosPage, Passive)…then the new page’s effect runs and subscribes toresize.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushPassiveEffects › commitHookEffectListMount
Under the hood: the recorded page switch
reconcileChildrenArray main ← [3 children]useFiber NavplaceChild Nav oldIndex 0 lastPlacedIndex 0useFiber buttonplaceChild button oldIndex 1 lastPlacedIndex 0createFiberFromTypeAndProps TodosPagedeleteChild CounterPageplaceChild TodosPage (new) lastPlacedIndex 1
commitRootcommitBeforeMutationEffectsflushMutationEffectscommitUpdate h1commitDeletionEffectsOnFiber CounterPagecommitDeletionEffectsOnFiber sectioncommitDeletionEffectsOnFiber h2commitDeletionEffectsOnFiber DisplaycommitDeletionEffectsOnFiber pcommitDeletionEffectsOnFiber text "Count: "commitDeletionEffectsOnFiber text "1"commitDeletionEffectsOnFiber buttonremoveChild <section> from <main>commitUpdate buttoncommitUpdate buttoncommitUpdate navcommitUpdate buttoncommitPlacement TodosPageinsertOrAppendPlacementNode TodosPage is not a DOM node → descend to its childinsertOrAppendPlacementNode appendChild(<section>) into <main>commitUpdate maincommitUpdate divflushLayoutEffectsflushPassiveEffectscommitHookEffectListUnmount CounterPage Passive[app] cleanup: title effect 1commitHookEffectListMount TodosPage HasEffect|Passive[app] effect: subscribe to resize
The fiber tree on the Todos page. CounterPage and its subtree are gone; the
TodoItem fibers carry their keys:
HostRoot (tag 3)
App (tag 0) hooks: ["todos", "dark"]
ThemeContext (tag 10)
Layout (tag 0) hooks: []
div (tag 5) → <div>
h1 (tag 5) → <h1>
main (tag 5) → <main>
Nav (tag 0) hooks: []
nav (tag 5) → <nav>
button (tag 5) → <button>
button (tag 5) → <button>
button (tag 5) → <button>
text "Theme: " (tag 6) → <#text>
text "dark" (tag 6) → <#text>
TodosPage (tag 0) hooks: [[{"id":1,"text":"Learn JSX"},{"id":2,"text":"Read about fibe, "", effect]
section (tag 5) → <section>
h2 (tag 5) → <h2>
form (tag 5) → <form>
input (tag 5) → <input>
button (tag 5) → <button>
ul (tag 5) → <ul>
TodoItem[key=1] (tag 0) hooks: []
li (tag 5) → <li>
text "Learn JSX" (tag 6) → <#text>
text " " (tag 6) → <#text>
button (tag 5) → <button>
TodoItem[key=2] (tag 0) hooks: []
li (tag 5) → <li>
text "Read about fibers" (tag 6) → <#text>
text " " (tag 6) → <#text>
button (tag 5) → <button>
10. Recap: the order of everything
| When | What runs | Creates |
|---|---|---|
| build | Babel / Oxc rewrites JSX to jsx() calls |
your compiled modules |
| page load | imports evaluate; createRoot; listenToAllSupportedEvents |
FiberRoot, HostRoot fiber, root listeners |
| page load | jsx(App); root.render() queues an update, schedules |
the <App/> element |
| render | work loop from HostRoot; each component called when reached | fibers (first time), new elements (every time) |
| render, going up | completeWork |
DOM nodes (detached), flags |
| commit | mutation → root.current swap → layout effects |
the DOM changes, all at once |
| after commit | paint, then useEffect (Sync lane: before paint) |
pixels; effect side effects |
setState |
queue + schedule; render from the root, bail out where possible | new elements for the components that run |
Rules of thumb that follow from the traces:
- A component runs when React’s work loop reaches its fiber, not when its
JSX is written.
<Nav />inAppdoesn’t callNav. childrenare created by the component that writes them, not the one that renders them.- Elements are recreated on every render; fibers and DOM nodes are reused when type (and key) match at the same position.
setStatenever renders by itself: it schedules a render. SeveralsetStates in one event produce one render.- The DOM is only touched in the commit, and only where flags say so.
Interview Q&A
What does JSX compile to?
A function call that returns an object. With the automatic runtime,
<Nav page="x" /> becomes jsx(Nav, { page: "x" }) imported from
react/jsx-runtime. With the classic runtime it was
React.createElement(Nav, { page: "x" }). Children go into
props.children, and key is passed separately.
When is a component function actually called?
When React’s work loop reaches its fiber in the render phase:
beginWork sees tag 0 and calls renderWithHooks, which calls your
function. Writing <Nav /> only creates an object { type: Nav, … }.
How does React know whether to call something or create a DOM node?
From element.type. A function becomes a FunctionComponent fiber (called).
A string becomes a HostComponent fiber (DOM node created in
completeWork). A context object becomes a provider fiber.
What does root.render(<App />) do?
It queues an update with { element: <App/> } on the HostRoot fiber and
schedules a render (microtask → Scheduler task for the Default lane). It
returns before anything is rendered.
Does React re-render the whole app when state changes?
The render starts at the root, but components above the updated one bail out without being called, and subtrees with no pending work are skipped. The component that owns the state and its descendants run (unless memoized).
In what order do effects run on a page switch?
Render the new page (off-screen), then in the commit remove the old DOM and insert the new one, then run the old page’s effect cleanups, then the new page’s effects.
How this was recorded
The demo lives in the site’s repository under examples/how-react-works.
npm run compileruns@babel/corewith@babel/preset-react(automatic, development and classic runtimes) on every source file, and asks Vite’s dev server for its output. Everything in chapter 1 is that output.npm run traceopens the app in Chrome, sets logpoints (breakpoints that log instead of pausing) on 49 functions of the React DOM 19.2.5 development build through the Chrome DevTools Protocol, and records them together with the app’s ownlog()calls. It then clicks “Add one”, “Theme”, “Todos”, types, adds and removes a todo. The traces, fiber trees and element objects above are that recording, filtered only to hide pointer/focus event noise and emptyreconcileChildFibers(… ← null)calls.
Development builds keep function names, which is why the traces read like the source. Production builds are minified, but run the same functions in the same order.
Primary source: github.com/facebook/react/tree/v19.2.5/packages