Must knowReact Internals · 11

How React Works, Start to Finish

A real 2-page app followed from JSX to pixels: Babel output, element objects, every component call, commit, clicks, lists and a page switch.

This section:trigger→schedule→render→commit→effects
On this page · 14 sections
  1. The whole journey in one picture
  2. 0. The app
  3. 1. Compile time: JSX becomes function calls
  4. 2. The element object {}
  5. 3. The page opens
  6. 4. The first render: how React decides what to call
  7. 5. Commit and the first paint
  8. 6. A click: state update and re-render
  9. 7. A context change
  10. 8. The list: keys at work
  11. 9. A page switch: unmount and mount
  12. 10. Recap: the order of everything
  13. Interview Q&A
  14. How this was recorded

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

  1. Compile time. Babel (or Vite’s Oxc) rewrites every <Tag /> into a function call: jsx(Tag, props). Your source no longer contains JSX.
  2. Page load. The browser runs main.jsx. createRoot sets React up, <App /> becomes an object, and root.render() schedules a render. No component has run yet.
  3. 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.
  4. Commit phase. React applies the recorded changes to the DOM in one synchronous pass, runs layout effects, and lets the browser paint. useEffect runs after.
  5. Updates. setState queues 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-works in the site’s repository: npm install && npm run dev) and open http://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:

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: {}
}

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() is running its return statement
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: … })
objects created (React elements)
{ type: Nav, props: { page: "counter", onNavigate } }
{ type: "button", props: { className: "theme", onClick, children: ["Theme: ", "light"] } }
{ type: CounterPage, props: {} }
{ type: Layout, props: { children: [ Nav el, button el, CounterPage el ] } }
{ type: ThemeContext, props: { value: "light", children: Layout el } }
  1. render phaseApp() → useState('counter'), useState('light')React calls App. Its body runs top to bottom: two useState calls, then the return expression is evaluated.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App()
  2. render phasejsx(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. Nav is not called: it’s just the type field.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App() › jsxDEV
  3. render phasejsxs("button", { …, children: ["Theme: ", "light"] })A host element: type is the string "button". Two children → jsxs with an array.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App() › jsxDEV
  4. render phasejsx(CounterPage, {})The ternary picks CounterPage. The TodosPage element is never created on this render.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App() › jsxDEV
  5. render phasejsxs(Layout, { children: [navEl, buttonEl, pageEl] })The three finished objects become Layout’s props.children. Layout will receive them; it does not create them.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App() › jsxDEV
  6. render phasejsx(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
  7. render phasereturn { type: ThemeContext, … }App returns one object (with others nested inside). Only App has run so far: Nav, Layout and CounterPage are still just references, and React decides when to call them.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App()
current stepcreated

0 / 0
Inside App’s render: each JSX expression is a function call that returns a plain object, innermost first.

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:

  1. Imports run first. react-dom/client, react and the Scheduler evaluate their top-level code, which mostly defines functions. Then your modules run, which defines App, Layout, … but calls none of them.
  2. 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 #root DOM node.
  3. listenToAllSupportedEvents(#root) attaches one listener per event type to #root, for both the capture and bubble phases. Your onClick={...} will never become a real addEventListener on a button.
  4. <App /> is evaluated before render is called (JavaScript evaluates arguments first): jsxDEV(App, {}) returns { type: App, props: {} }.
  5. 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 calls scheduleUpdateOnFiber.
  6. render() returns. It doesn’t render anything. ensureRootIsScheduled queued a microtask, and main.jsx finishes with #root still empty.
  7. The microtask (processRootScheduleInMicrotask) sees a Default lane. That isn’t Sync, so it asks the Scheduler for a task.
  8. The Scheduler task (performWorkOnRootViaSchedulerTask) runs performWorkOnRoot(root, 32) → renderRootSync. The render begins.
main.jsx (compiled)
import { createRoot } from 'react-dom/client'
import { App } from './App.jsx'
const root = createRoot(document.getElementById('root'))
root.render(jsxDEV(App, {}))
React’s memory
React modules: not loadedFiberRootHostRoot fiber (current)listeners on #root{ type: App, props: {} }HostRoot update queue: [ ]scheduled: nothing
the page
<div id="root"></div>
main thread: running main.jsx
  1. triggerimport 'react-dom/client'The browser loads index.html, which loads main.jsx as 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)
  2. triggerimport { App } from './App.jsx'Your component modules run too, which only defines App, Layout, … No component is called by importing it.stack: main.jsx (module)
  3. triggercreateRoot(container) → createFiberRoot(…)React creates a FiberRoot (bookkeeping for this root) and the first fiber, HostRoot, then stores a pointer to it on the #root DOM node.stack: main.jsx (module) › createRoot › createFiberRoot
  4. triggerlistenToAllSupportedEvents(#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
  5. triggerjsxDEV(App, {})Arguments are evaluated first, so <App /> becomes an element object. App still hasn’t run.stack: main.jsx (module) › jsxDEV
  6. scheduleroot.render(element) → requestUpdateLane(HostRoot) = DefaultLane (32)No event is happening, so the update gets the Default lane.stack: main.jsx (module) › root.render › requestUpdateLane
  7. scheduleupdateContainerImpl → 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
  8. scheduleensureRootIsScheduled → scheduleImmediateRootScheduleTask (microtask)render() returns without rendering. main.jsx finishes; #root is still empty.stack: main.jsx (module) › root.render › updateContainerImpl › scheduleUpdateOnFiber › ensureRootIsScheduled › scheduleImmediateRootScheduleTask
  9. scheduleprocessRootScheduleInMicrotask → Scheduler taskThe microtask sees a Default lane (not Sync), so it asks the Scheduler for a task. The Scheduler posts it as a macrotask (via MessageChannel).stack: processRootScheduleInMicrotask › scheduleCallback
  10. render phaseperformWorkOnRootViaSchedulerTask → 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
current stepcreatedreused / keptupdated / movedcompletedok

0 / 0
From script load to the first render being scheduled. Note how much happens before any component runs.
Under the hood: the recorded trace, and the source

The real call order, up to the start of the render:

browser eventschedulingrender phaseelement createdcommit phaseyour component’s log()indent = called by the line above · ▾ folds · tap a name for its card
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.

HostRoot
App
ThemeContext
Layout
div
h1
main
Nav
nav2 buttons
button"Theme: light"
CounterPage
section
h2
Display
p"Count: 0"
button"Add one"
  1. render phasebeginWork(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
  2. render phasecreateFiberFromTypeAndProps(App) → tag 0React needs a fiber for the App element. typeof App === "function" → tag 0 (FunctionComponent).stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(HostRoot) › reconcileChildren › reconcileChildFibers › reconcileSingleElement › createFiberFromTypeAndProps
  3. render phasebeginWork(App) → renderWithHooks(App) → App(props)Now App is called for the first time. Its useStates are created, and its jsx() calls create 5 elements.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App() › mountState
  4. render phasecreateFiberFromTypeAndProps(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
  5. render phasebeginWork(ThemeContext) → pushProvider · createFiber(Layout)Providers aren’t called. React pushes value: "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
  6. render phasebeginWork(Layout) → renderWithHooks → Layout({ children })Layout is called with props.children: the elements App already created. Layout creates only div, h1, main.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Layout) › updateFunctionComponent › renderWithHooks › Layout()
  7. render phasebeginWork(div) → reconcileChildrenArray(div, [h1, main])A string type → tag 5 (HostComponent). Host fibers aren’t called; React reconciles their props.children.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(div) › reconcileChildren › reconcileChildFibers › reconcileChildrenArray › createFiberFromTypeAndProps
  8. render phasebeginWork(h1) → completeWork(h1)h1 has 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)
  9. render phasebeginWork(main) → reconcileChildrenArray(main, [Nav, button, CounterPage])main’s children are the three elements App created: Nav, the theme button, 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
  10. render phaseNav({ page, onNavigate }) → … completeWork(button, button, nav, Nav)Nav is called and returns a <nav> with two buttons. They complete, then Nav completes: go across to the sibling.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Nav) › updateFunctionComponent › renderWithHooks › Nav()
  11. render phasebeginWork(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)
  12. render phasebeginWork(CounterPage) → CounterPage()CounterPage is called: useState(0), useRef, useLayoutEffect and useEffect are registered (effects only recorded, not run).stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(CounterPage) › updateFunctionComponent › renderWithHooks › CounterPage() › mountState
  13. render phasebeginWork(section) → reconcileChildrenArray(section, [h2, Display, button])section’s three children get fibers. h2 completes right away.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(section) › reconcileChildren › reconcileChildFibers › reconcileChildrenArray › createFiberFromTypeAndProps
  14. render phasebeginWork(Display) → Display({ value: 0 })Display is called with { value: 0 } and returns <p>Count: {value}</p>.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Display) › updateFunctionComponent › renderWithHooks › Display()
  15. render phasebeginWork(p) → … → completeWork(p) · completeWork(Display)The p gets two text fibers, completes, and Display completes.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(p)
  16. render phasebeginWork(button) → completeWork(button)The last leaf: the “Add one” button.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(button)
  17. render phasecompleteWork(section, CounterPage, main, div, Layout, ThemeContext, App, HostRoot)No more siblings: React climbs to the root, completing each parent. workInProgress becomes null. The render is finished, but nothing is on screen yet.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(HostRoot)
current stepcomponent runningcreatedcompleted

0 / 0
The first render: one fiber at a time, down with 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:

browser eventschedulingrender phaseelement createdcommit phaseyour component’s log()indent = called by the line above · ▾ folds · tap a name for its card
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:

browser eventschedulingrender phaseelement createdcommit phaseyour component’s log()indent = called by the line above · ▾ folds · tap a name for its card
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):

  1. Mutation. The App fiber carries the Placement flag. It isn’t a DOM node, so React descends (App → ThemeContext → Layout) until it finds one, and does one appendChild(<div>) into #root.
  2. Swap. root.current = finishedWork: the new tree is now the current one.
  3. Layout effects. useLayoutEffect runs. The DOM exists and can be measured, but nothing has been painted.
  4. Paint. The Scheduler task ends; the browser paints.
  5. Passive effects. Because this render used the Default lane, useEffect runs in a later task, after the paint.
DOM built in memory (completeWork)
<div class="layout light">
  <h1>How React Works</h1>
  <main>
    <nav>…2 buttons…</nav>
    <button>Theme: light</button>
    <section> h2 · p "Count: 0" · button </section>
document
<div id="root">(empty)</div>
pixels: blank page
console (?trace)
layout effect: button is 88 px wide
effect: set title 0
  1. render phasecompleteWork(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)
  2. render phasecompleteWork(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)
  3. render phasecompleteWork(section) → appendAllChildren(h2, p, button)Display is a component, not a DOM node, so React reaches through it and appends its <p>.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(section)
  4. render phasecompleteWork(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)
  5. commit phasecommitRoot → flushMutationEffects → commitPlacement(App)Commit starts. App is the fiber flagged Placement. It isn’t a DOM node, so React descends: App → ThemeContext → Layout → div.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitPlacement
  6. commit phaseinsertOrAppendPlacementNodeIntoContainer → appendChild(<div>) into #rootOne DOM insertion puts the whole tree into the document. Then root.current = finishedWork.stack: performWorkOnRootViaSchedulerTask › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitPlacement › insertOrAppendPlacementNode
  7. commit phaseflushLayoutEffects → 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
  8. browsertask ends → style · layout · paintThe Scheduler task returns and the browser paints: the user sees the app.stack:
  9. after paintflushPassiveEffects → commitHookEffectListMount(CounterPage, Passive)This render used the Default lane, so useEffect runs 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
current stepcreatedreused / keptupdated / movedcompletedok

0 / 0
The DOM is built off-screen during render, inserted with one appendChild during commit, then painted.
Under the hood: the recorded commit
browser eventschedulingrender phaseelement createdcommit phaseyour component’s log()indent = called by the line above · ▾ folds · tap a name for its card
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:

  1. The event. The browser dispatches click. React’s listener on #root runs (dispatchDiscreteEvent, once for the capture phase and once for bubble), finds the fiber of the clicked button, collects the onClick props on the path, and calls yours inside a SyntheticEvent.
  2. setCount(1) calls dispatchSetState(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.
  3. Render, starting from the root. React marks the path: CounterPage.lanes, and childLanes on every ancestor. Then the work loop starts at HostRoot again. Ancestors with only childLanes bail out: their functions are not called. Siblings with no work (h1, Nav, the theme button) are skipped with their entire subtree.
  4. CounterPage is called because it has the update. useState now returns 1. Its jsx() calls create brand-new element objects, the same way as on the first render.
  5. 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. Display is called (its props object is new), and its text child changes from "0" to "1".
  6. Commit. One DOM write: the text node becomes "1". The layout effect reruns (its deps changed). Because this was a SyncLane render, useEffect is flushed at the end of the commit: first the old effect’s cleanup (with count 0), then the new effect.
  7. Paint. The microtask ends and the browser paints “Count: 1”.
HostRoot
App
ThemeContext
Layout
div
h1
main
Nav
nav2 buttons
button"Theme: light"
CounterPageflag
section
h2
Display
p"Count: 0"flag
button"Add one"
lane: –scheduled: nothingscreen: Count: 0
cleanup: title effect 0
effect: set title 1
  1. eventdispatchDiscreteEvent("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, collects onClick props along the path, and calls yours.stack: dispatchDiscreteEvent › dispatchEvent › onClick
  2. eventsetCount(1) → dispatchSetState(CounterPage, 1)Your handler calls setCount(1). An update is queued on CounterPage’s state hook. Nothing renders yet.stack: dispatchDiscreteEvent › dispatchEvent › onClick › dispatchSetState
  3. schedulescheduleUpdateOnFiber(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
  4. render phaseprocessRootScheduleInMicrotask → performSyncWorkOnRoot → markUpdateLaneFromFiberToRootThe microtask renders. First React marks the path: CounterPage gets lanes, every ancestor gets childLanes.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › prepareFreshStack › finishQueueingConcurrentUpdates › markUpdateLaneFromFiberToRoot
  5. render phasebeginWork(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
  6. render phasebeginWork(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
  7. render phaserenderWithHooks(CounterPage) → CounterPage() → updateReducer → count = 1CounterPage has the update → it’s called. useState returns 1, and new element objects are created for its whole output.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(CounterPage) › updateFunctionComponent › renderWithHooks › CounterPage() › updateReducer
  8. render phasereconcileChildrenArray(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
  9. render phaseDisplay({ value: 1 }) → text "0" → "1"Display receives 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()
  10. render phasecompleteWork(… → HostRoot)React climbs back to the root. Only CounterPage and Display ran.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(HostRoot)
  11. commit phaseflushMutationEffects → commitTextUpdate("0" → "1")One text write. (commitUpdate is also called for the re-rendered host nodes, but it finds no changed props.)stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitTextUpdate
  12. commit phaseflushLayoutEffects → 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
  13. after paintflushPassiveEffects (SyncLane: at the end of the commit)For a click, React flushes useEffect right away: first the previous effect’s cleanup (with the old count, 0)…stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushPassiveEffects › commitHookEffectListUnmount
  14. after paintcommitHookEffectListMount(CounterPage, Passive)…then the new effect with count = 1.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushPassiveEffects › commitHookEffectListMount
  15. browsermicrotask ends → paintThe browser paints “Count: 1”.stack:
current stepbeing comparedcomponent runningreused / keptupdated / movedbailed outskippedcompletedok

0 / 0
One click on “Add one”: only the component that owns the state and its children run. Everything above bails out.

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
browser eventschedulingrender phaseelement createdcommit phaseyour component’s log()indent = called by the line above · ▾ folds · tap a name for its card
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
browser eventschedulingrender phaseelement createdcommit phaseyour component’s log()indent = called by the line above · ▾ folds · tap a name for its card
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”):

browser eventschedulingrender phaseelement createdcommit phaseyour component’s log()indent = called by the line above · ▾ folds · tap a name for its card
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:

browser eventschedulingrender phaseelement createdcommit phaseyour component’s log()indent = called by the line above · ▾ folds · tap a name for its card
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:

browser eventschedulingrender phaseelement createdcommit phaseyour component’s log()indent = called by the line above · ▾ folds · tap a name for its card
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
browser eventschedulingrender phaseelement createdcommit phaseyour component’s log()indent = called by the line above · ▾ folds · tap a name for its card
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:

browser eventschedulingrender phaseelement createdcommit phaseyour component’s log()indent = called by the line above · ▾ folds · tap a name for its card
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]
browser eventschedulingrender phaseelement createdcommit phaseyour component’s log()indent = called by the line above · ▾ folds · tap a name for its card
commitDeletionEffectsOnFiber  TodoItem[key=1]commitDeletionEffectsOnFiber  licommitDeletionEffectsOnFiber  text "Learn JSX"commitDeletionEffectsOnFiber  text " "commitDeletionEffectsOnFiber  buttonremoveChild  <li> from <ul>
Add a todo
old fibers
key 1“Learn JSX”
key 2“Read about…”
new elements
key 1“Learn JSX”
key 2“Read about…”
key 3“x”
startlastPlacedIndex = 0map
DOM <ul>
<li>“Learn JSX”
<li>“Read about…”
<li>“x”
  1. eventsubmit → setTodos([...]) · setText("")Two state updates in one handler. The trace shows one render: both were queued and the microtask processed them together (batching).
  2. render phaseTodosPage() → todos.map → jsx(TodoItem, {…}, 1 / 2 / 3)TodosPage re-renders and creates three TodoItem elements. The key is the third argument of jsx().
  3. render phasereconcileChildrenArray(ul) · updateSlot(key 1, key 1) · placeChildPass 1 walks both lists while keys match. Key 1 = key 1 → reuse.
  4. render phaseupdateSlot(key 2, key 2) · placeChildKey 2 = key 2 → reuse. lastPlacedIndex becomes 1.
  5. render phasecreateFiberFromTypeAndProps(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.
  6. commit phasecommitPlacement(TodoItem[key=3]) → appendChild(<li>) into <ul>One DOM insertion.
current stepbeing comparedcreatedreused / keptupdated / moveddeleted

0 / 0
The todo list with keys: adding appends one <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:

  1. Render: TodosPage and its items are called and their DOM is built off-screen. CounterPage is only marked for deletion.
  2. Commit, mutation: React walks the deleted subtree (commitDeletionEffectsOnFiber), then removes its top DOM node with one removeChild(<section>), and inserts the new <section> with one appendChild.
  3. Passive effects: first the unmount cleanups (cleanup: title effect), then the new effects (effect: subscribe to resize).
mainflag
Nav
button
CounterPagecount: 1
section
h2
Display
button
TodosPageflag
section
form
ul
TodoItemkey 1
TodoItemkey 2
DOM inside <main>
<nav> · <button> · <section>Counter…</section>
console (?trace)
cleanup: title effect 1
effect: subscribe to resize
  1. eventonNavigate('todos') → dispatchSetState(App, 'todos')The “Todos” button calls onNavigate, which is App’s setPage. The update goes on App, which owns the state.stack: dispatchDiscreteEvent › dispatchEvent › onClick › dispatchSetState
  2. render phaseApp() → page === "todos" → jsx(TodosPage, {})App re-renders. This time the ternary creates a TodosPage element in the third slot of Layout’s children.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App() › jsxDEV
  3. render phaseLayout() · 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()
  4. render phasereconcileChildrenArray(main, [Nav, button, TodosPage])Slots 0 and 1 match. Slot 2: element type TodosPage vs fiber type CounterPage → different type.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(main) › reconcileChildren › reconcileChildFibers › reconcileChildrenArray
  5. render phasecreateFiberFromTypeAndProps(TodosPage) · deleteChild(CounterPage)A new fiber for TodosPage (flagged Placement), and CounterPage is added to main.deletions (main flagged ChildDeletion). count: 1 will be lost.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(main) › reconcileChildren › reconcileChildFibers › reconcileChildrenArray › deleteChild
  6. render phaseTodosPage() → mountState([…]), mountState(''), useEffectTodosPage mounts: fresh state. Its todos.map creates two TodoItem elements with keys 1 and 2.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(TodosPage) › updateFunctionComponent › renderWithHooks › TodosPage() › mountState
  7. render phaseTodoItem[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()
  8. commit phasecommitDeletionEffectsOnFiber(CounterPage → section → …)Mutation phase: React walks the deleted subtree (detaching refs, running layout cleanups)…stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitDeletionEffects
  9. commit phaseremoveChild(<section>) from <main>…then removes only the top DOM node of that subtree.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitDeletionEffects › removeChild
  10. commit phasecommitPlacement(TodosPage) → appendChild(<section>) into <main>The new subtree goes in with one insertion.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitPlacement › insertOrAppendPlacementNode
  11. after paintflushPassiveEffects → commitHookEffectListUnmount(CounterPage, Passive)Passive effects: unmount cleanups first. The deleted CounterPage’s title effect cleans up…stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushPassiveEffects › commitHookEffectListUnmount
  12. after paintcommitHookEffectListMount(TodosPage, Passive)…then the new page’s effect runs and subscribes to resize.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushPassiveEffects › commitHookEffectListMount
current stepbeing comparedcomponent runningcreatedupdated / movedcompleteddeleted

0 / 0
Switching pages is Rule 1 of reconciliation: a different type at the same position deletes one subtree and mounts another.
Under the hood: the recorded page switch
browser eventschedulingrender phaseelement createdcommit phaseyour component’s log()indent = called by the line above · ▾ folds · tap a name for its card
reconcileChildrenArray  main ← [3 children]useFiber  NavplaceChild  Nav oldIndex 0 lastPlacedIndex 0useFiber  buttonplaceChild  button oldIndex 1 lastPlacedIndex 0createFiberFromTypeAndProps  TodosPagedeleteChild  CounterPageplaceChild  TodosPage (new) lastPlacedIndex 1
browser eventschedulingrender phaseelement createdcommit phaseyour component’s log()indent = called by the line above · ▾ folds · tap a name for its card
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:

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.

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