A tour of the React repository
Find your way around React's source at v19.3.1: the packages, how react-dom plugs into the reconciler, and how npm's builds map back to the code.
- 01Find where a feature livesName the folder and file in facebook/react that holds useState, createRoot, the work loop, the DOM code, and the scheduler.
- 02Explain how renderers plug inDescribe how react hands hooks to the renderer through a dispatcher, and how the reconciler calls a host config to change the DOM.
- 03Read the source and the buildsMap the files npm installs back to the source, read __DEV__ blocks and feature flags, and follow React's functions in a stack trace.
Three imports, one repository
This stage reads React's own code. Before you open a single function, you need a map: which folder holds what, and how the pieces you install connect. Start with a small app you already know how to read. Click its button.
import { useState, version } from "react";import { version as domVersion } from "react-dom"; export default function App() { const [count, setCount] = useState(0); return ( <> <p>react: {version}</p> <p>react-dom: {domVersion}</p> <button onClick={() => setCount(count + 1)}>Add tea ({count})</button> </> );}Line 1 imports useState and version from the react package. Line 2 imports version from a second package, react-dom, and renames it domVersion. Lines 9 and 10 print both: 19.3.1 and 19.3.1. The two packages always ship together, with the same number.
There are two more imports you don't see. Before the code runs, a compiler turns the JSX on lines 8 to 12 into calls to jsx(), imported from react/jsx-runtime (the Writing markup with JSX lesson shows this). And every live example has an entry file, main.jsx, which you can see with Pop out:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>
);Line 2 imports createRoot from react-dom/client, and line 5 hands it the page's <div id="root">. So one small app reads code from react, react/jsx-runtime, and react-dom/client, and react-dom quietly loads a fourth package, scheduler. All of them are built from one GitHub repository, facebook/react. Every link in this lesson opens it at the tag v19.3.1, the release these examples run. A tag never changes, so line numbers stay right; the main branch changes every day.
packages/ holds React itself: the four folders on the left are published to npm, and the four on the right are never installed on their own; their code is copied into the packages that use them. compiler/ holds the React Compiler.A touring play has one script and one director. In each city, a local crew builds the sets with whatever that theatre has. The writer never needs to know which stage the play is on, and the director gives every crew the same kind of instructions.
- In real life: The script the writer hands in
- In React: Your components, written with the react package
- In real life: The director, who works out what changes between scenes
- In React: The reconciler, which works out what changed between renders
- In real life: Each theatre's stage crew, who move that building's own sets and lights
- In React: A host config, which creates and changes one host's nodes
- In real life: The same play opening in a new theatre
- In React: The same components shown by another renderer, like React Native
React is one repository that builds many npm packages. You write components with react, which describes the UI. A renderer such as react-dom shows it. Inside every renderer is the same reconciler, which works out what changed, and a host config, the renderer's own functions that create and change real nodes.
The packages folder: one repository, many packages
A repository that holds many packages is called a monorepo. React's packages sit side by side in packages/, one folder each, and the build turns some of them into the npm packages you install. Others are never installed on their own: the build copies their code into the packages that use them. In the code, a file imports another package by its name, such as import ... from 'shared/ReactSymbols', even when that package is never published.
How to read the table: each row is one folder, the name npm knows it by, and what it holds.
| Folder | On npm as | What it holds |
|---|---|---|
packages/react | react | Everything you import from react: the hooks, createElement, the JSX runtime, memo, lazy. It describes UI and never draws it. |
packages/react-dom | react-dom | The web renderer's entry points: createRoot, hydrateRoot, the server renderers, and APIs such as preload. |
packages/react-dom-bindings | Not published | The DOM code: the host config that creates and changes DOM nodes, and the event system. It is built into react-dom. |
packages/react-reconciler | react-reconciler, for custom renderers | Fibers, the work loop, hooks, and the commit phase. Every renderer copies it into its own build. |
packages/scheduler | scheduler | Runs tasks by priority and lets the browser respond between them. react-dom loads it at run time. |
packages/shared | Not published | Small pieces many packages use: the element symbols, the feature flags, helpers such as assign. |
packages/react-server | Not published | Server rendering: Fizz writes HTML, and Flight sends Server Components. It is built into react-dom/server and the Server Components packages. |
packages/react-client | Not published | The Flight client, which reads a Server Components stream in the browser. |
compiler/ | babel-plugin-react-compiler and others | The React Compiler, a workspace of its own, written in TypeScript. |
Three things stand out. First, react is small: it holds the functions you call, but none of the code that renders. Second, the reconciler and the DOM code are separate folders, but you get both inside react-dom's files. Third, the compiler/ folder is a workspace of its own, written in TypeScript, with its own packages; it runs while your app is built, not in the browser. Other folders hold more renderers (react-native-renderer, react-art, react-test-renderer), the DevTools, and the hooks lint rules in eslint-plugin-react-hooks.
Now try the map. Pick something you use every day, and the playground shows the folder, the files and lines at v19.3.1, and the file under node_modules where the same code ends up.
packages/react- You write
const [count, setCount] = useState(0);useState- packages/react/src/ReactHooks.js, line 66Two lines: it asks for the current dispatcher and calls its useState.
mountState- packages/react-reconciler/src/ReactFiberHooks.js, line 1948The real work on the first render: it makes the hook and its update queue for this component.
- In node_modules
react/cjs/react.development.jsSearch forreturn resolveDispatcher().useState(initialState);
useState is exported by react, but react only passes the call on. The code that keeps your state is in the reconciler, which react-dom carries inside its own file.
Try useState first. You import it from react, but the first file is only a doorway: the state is kept by mountState in the reconciler. Then try A <li> appearing on the page: in the built file, createInstance has no function of its own any more, because the build copied it into the function that calls it. Searching for a distinctive line, like createElement(type), finds it anyway.
react describes, the renderer does the work
If react has no rendering code, how does useState work? Here is the whole function, from ReactHooks.js. (The source uses Flow, a type checker like TypeScript: <S> and the text after each colon are types, and you can read past them.)
export function useState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
const dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}In plain words, line by line:
- Lines 1 to 3 declare
useState: it takes a starting value and returns a pair. - Line 4 asks
resolveDispatcher()for the current dispatcher: an object holding the real versions of every hook. - Line 5 calls that object's
useStateand returns whatever it returns. That's all.
resolveDispatcher is in the same file, at lines 24 to 42:
function resolveDispatcher() {
const dispatcher = ReactSharedInternals.H;
if (__DEV__) {
if (dispatcher === null) {
console.error(
'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
' one of the following reasons:\n' +
'1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
'2. You might be breaking the Rules of Hooks\n' +
'3. You might have more than one copy of React in the same app\n' +
'See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.',
);
}
}
// Will result in a null access error if accessed outside render phase. We
// intentionally don't throw our own error because this is in a hot path.
// Also helps ensure this is inlined.
return dispatcher as any as Dispatcher;
}- Line 2 reads
ReactSharedInternals.H.ReactSharedInternalsis an object thatreactexports so renderers can reach it, andHis the slot for the hooks dispatcher. - Lines 3 to 14 only exist in development builds (
__DEV__, explained below): if the slot is empty, React prints the Invalid hook call message. - Lines 15 to 18 return the slot as it is, even when it's
null. The comment says why: this runs on every hook call, so React doesn't spend time on a check of its own.
So who fills the slot? The reconciler, just before it calls your component. This is the production branch of renderWithHooks in ReactFiberHooks.js, lines 562 to 567:
} else {
ReactSharedInternals.H =
current === null || current.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
}Lines 2 to 5 put one of two dispatchers in H: HooksDispatcherOnMount the first time a component renders (current, React's record of the component from the last render, is null, or it has no saved hooks), and HooksDispatcherOnUpdate after that. That's how the same useState(0) line creates state on the first render and reads it on later ones. When the component returns, renderWithHooks calls finishRenderingHooks, which puts ContextOnlyDispatcher back, at line 660.
react's useState only reads one slot, H. While react-dom renders your component, renderWithHooks puts a dispatcher there, the object with the real hook functions. Outside a render the slot holds null, or a dispatcher whose hooks throw.You can watch the slot yourself. react exports ReactSharedInternals under a name that warns you off, __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE, at line 110 of ReactClient.js. Its contents change between releases, so never use it in an app; here it's only a window to look through. Click the button.
import { useState, __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE as internals } from "react"; const beforeAnyRender = internals.H; export default function App() { const [afterRender, setAfterRender] = useState("not clicked yet"); const duringRender = internals.H; function check() { try { internals.H.useState(0); setAfterRender("useState worked"); } catch (error) { setAfterRender(error.message.split(".")[0]); } } return ( <> <p>Before any render: {String(beforeAnyRender)}</p> <p>During this render: an object with {Object.keys(duringRender).length} hooks</p> <button onClick={check}>Call H.useState from a click</button> <p>From a click: {afterRender}</p> </> );}Line 3 runs when the file loads, before anything has rendered: the result shows Before any render: null. Line 7 runs inside App, during a render: H is the dispatcher, an object with 24 hook functions, from readContext and use to useEffectEvent. The button calls H.useState from a click, after the render is over, and line 14 shows the first sentence of the error: Invalid hook call.
Why an error, and not null again? The work loop explains it, in ReactFiberWorkLoop.js:
function pushDispatcher(container: any) {
const prevDispatcher = ReactSharedInternals.H;
ReactSharedInternals.H = ContextOnlyDispatcher;
if (prevDispatcher === null) {
// The React isomorphic package does not include a default dispatcher.
// Instead the first renderer will lazily attach one, in order to give
// nicer error messages.
return ContextOnlyDispatcher;
} else {
return prevDispatcher;
}
}Before each render, line 3 sets H to ContextOnlyDispatcher, a dispatcher whose hooks all throw. The function returns what was there before (line 10), and when the render ends, the work loop puts that back. On the very first render there was nothing, so lines 4 to 8 return ContextOnlyDispatcher instead. The comment says it: react ships with no dispatcher, and the first renderer will lazily attach one, in order to give nicer error messages.
From then on, a hook called outside a render throws a clear error instead of reading null.
The reconciler and host configs
The reconciler, in packages/react-reconciler, is the engine: it keeps a fiber, a small record, for each component and element (the fiber lesson later in this stage opens one), calls your components, compares the new elements with the old, and decides what to create, change, or remove. It never touches a DOM node. Every time it needs the host, it calls a function it imports from one file:
import {
supportsMutation,
supportsResources,
supportsSingletons,
commitMount,
commitUpdate,
resetTextContent,
commitTextUpdate,
appendChild,
appendChildToContainer,
insertBefore,
// … 17 more
} from './ReactFiberConfig';This is the top of ReactFiberCommitHostEffects.js, which applies changes during the commit. Each name is a job only the host knows how to do: commitUpdate changes an element's props, commitTextUpdate changes text, appendChild and insertBefore place a node. Together, these functions are the host config. But open ReactFiberConfig.js itself, and it holds one line of code:
throw new Error('This module must be shimmed by a specific renderer.');The file is a placeholder. When React builds react-dom, the build tool swaps it for a fork, a replacement file named after the renderer. scripts/rollup/forks.js does the swap, and forks/ holds one file per host: art, custom, dom, fabric (React Native), markup, noop, and test. The DOM one is two lines:
export * from 'react-dom-bindings/src/client/ReactFiberConfigDOM';
export * from 'react-client/src/ReactClientConsoleConfigBrowser';Line 1 passes on everything from ReactFiberConfigDOM.js, the DOM host config. Line 2 adds how React prints to the browser's console. Inside the DOM host config, the functions are plain DOM code. Here is the one that changes text, at lines 1018 to 1024:
export function commitTextUpdate(
textInstance: TextInstance,
oldText: string,
newText: string,
): void {
textInstance.nodeValue = newText;
}Lines 2 to 4 are the text node, the old text, and the new text. Line 6 is the whole job: it sets the text node's nodeValue, the same thing you'd do by hand (the Node properties lesson covers nodeValue). A test renderer does something else entirely. Its createInstance, in ReactFiberConfigTestHost.js, makes no DOM node at all:
export function createInstance(
type: string,
props: Props,
rootContainerInstance: Container,
hostContext: Object,
internalInstanceHandle: Object,
): Instance {
return {
type,
props,
isHidden: false,
children: [],
internalInstanceHandle,
rootContainerInstance,
tag: 'INSTANCE',
};
}Lines 8 to 16 return a plain object with the element's type, its props, and an empty children array. Same reconciler, same call, a different host. React Native's createInstance asks the phone for a native view with createNode instead.
createInstance and appendChild, that do the work for its host: DOM elements, native views, or plain objects in a test.You can watch the DOM host config work. The second file below, spy.js, replaces document.createElement (lines 35 to 43 of its tab) so that every <ul> and <li> React creates gets watched: its appendChild, insertBefore, and removeChild (lines 9 to 20), and writes to its textContent (lines 21 to 32), are recorded before they run. App.jsx shows the record after each commit, with a layout effect (lines 8 to 10). Click the three buttons in order.
import { useLayoutEffect, useState } from "react";import { calls } from "./spy.js"; export default function App() { const [items, setItems] = useState(["Tea", "Cake"]); const [log, setLog] = useState([]); useLayoutEffect(() => { if (calls.length > 0) setLog(calls.splice(0)); }); return ( <> <button onClick={() => setItems([...items, "Lemonade"])}>Add lemonade</button> <button onClick={() => setItems(["Mint", ...items])}>Add mint first</button> <button onClick={() => setItems(items.slice(1))}>Remove first</button> <ul> {items.map((name) => ( <li key={name}>{name}</li> ))} </ul> <pre>{log.join("\n")}</pre> </> );}The record answers questions you could only guess at before:
- First render. React creates the two
<li>elements and sets their text, then creates the<ul>, then appends the items to it. It builds from the leaves up, while rendering, before anything is on the page. (The last call, putting the<ul>into#root, isn't recorded: the spy only watches lists.) - Add lemonade. One new
<li>, thenul.appendChild: the host config's appendChild. Tea and Cake are untouched. - Add mint first. A new item at the front needs insertBefore, placed before Tea.
- Remove first. One call, removeChild, for Mint. The other items stay where they are, because their keys didn't change.
From source files to the files npm installs
Nobody installs the source. When you install react, npm gives you files that React's build made from it. The entry point you import is tiny. This is packages/react/npm/jsx-runtime.js, which npm installs as node_modules/react/jsx-runtime.js:
'use strict';
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react-jsx-runtime.production.js');
} else {
module.exports = require('./cjs/react-jsx-runtime.development.js');
}Line 3 reads process.env.NODE_ENV, a value your bundler sets: "production" for the build you ship, "development" while you work. Line 4 loads the production file, line 6 the development file. Both sit in the package's cjs folder (CommonJS, the older module format with require; see CommonJS and ES modules). The pattern repeats for react, react-dom/client, and the rest.
The two files come from the same source. The build is a Rollup script, scripts/rollup/build.js, and this part decides what __DEV__ means:
// Turn __DEV__ and process.env checks into constants.
replace({
preventAssignment: true,
values: {
__DEV__: isProduction ? 'false' : 'true',
__PROFILE__: isProfiling || !isProduction ? 'true' : 'false',
'process.env.NODE_ENV': isProduction
? "'production'"
: "'development'",
__EXPERIMENTAL__,
},
}),- Line 5 replaces every
__DEV__in the source with the textfalsefor a production file andtruefor a development file. So__DEV__isn't a variable your app can change; it's gone before you install React. - Line 6 does the same for
__PROFILE__, which turns on timing code for the profiler. - Lines 7 to 9 write
process.env.NODE_ENVas a fixed string into each file. - Line 10 fills in
__EXPERIMENTAL__:truefor React's experimental channel,falsefor a stable release like 19.3.1.
After the replacement, a block like if (false) { … } can never run, and the build removes it. That's how one source file gives a development file full of warnings and a production file with none. The same goes for feature flags, the switches in packages/shared/ReactFeatureFlags.js that turn unfinished features on or off. Here is one, at line 110:
export const enableOptimisticKey = __EXPERIMENTAL__;enableOptimisticKey is on only in experimental builds. When you read the source and meet if (enableOptimisticKey && …), look the flag up in this file: in your stable 19.3.1 build, that branch doesn't exist. (Builds for Meta's own apps and React Native use other copies of this file, in packages/shared/forks.)
__DEV__ becomes true in the development file and false in the production file, and code that can never run is removed. react/jsx-runtime.js, the file your bundler opens, picks one by process.env.NODE_ENV.See it on a real function. jsxProd builds an element in production. Switch between the source, the production file, and what jsx is in the development file:
export function jsxProd(type, config, maybeKey) {
let key = null;
// Currently, key can be spread in as a prop. This causes a potential
// issue if key is also explicitly declared (ie. <div {...props} key="Hi" />
// or <div key="Hi" {...props} /> ). We want to deprecate key spread,
// but as an intermediary step, we will use jsxDEV for everything except
// <div {...props} key="Hi" />, because we aren't currently able to tell if
// key is explicitly declared to be undefined or not.
if (maybeKey !== undefined) {
if (enableOptimisticKey && maybeKey === REACT_OPTIMISTIC_KEY) {
key = REACT_OPTIMISTIC_KEY;
} else {
if (__DEV__) {
checkKeyStringCoercion(maybeKey);
}
key = '' + maybeKey;
}
}
if (hasValidKey(config)) {
if (enableOptimisticKey && maybeKey === REACT_OPTIMISTIC_KEY) {
key = REACT_OPTIMISTIC_KEY;
} else {
if (__DEV__) {
checkKeyStringCoercion(config.key);
}
key = '' + config.key;
}
}
let props;
if (!('key' in config)) {
// If key was not spread in, we can reuse the original props object. This
// only works for `jsx`, not `createElement`, because `jsx` is a compiler
// target and the compiler always passes a new object. For `createElement`,
// we can't assume a new object is passed every time because it can be
// called manually.
//
// Spreading key is a warning in dev. In a future release, we will not
// remove a spread key from the props object. (But we'll still warn.) We'll
// always pass the object straight through.
props = config;
} else {
// We need to remove reserved props (key, prop, ref). Create a fresh props
// object and copy over all the non-reserved props. We don't use `delete`
// because in V8 it will deopt the object to dictionary mode.
props = {};
for (const propName in config) {
// Skip over reserved prop names
if (propName !== 'key') {
props[propName] = config[propName];
}
}
}
return ReactElement(type, key, props, getOwner(), undefined, undefined);
}The function as React's team writes it: 58 lines, with comments, two checks of the feature flag enableOptimisticKey, and two __DEV__ blocks. It ends by calling ReactElement, which builds the object.
In Source, find the two enableOptimisticKey checks and the two __DEV__ blocks. In Production build, all four are gone, and the 58 lines became 18. Comments are gone too, and ReactElement, a separate function in the source, has been copied into its only caller, so the object is built right there. Development build shows that in development, jsx is a different function, one that records extra information for warnings. You can open these files yourself, in your own project, under node_modules/react/cjs/.
How to read the table: each row asks one question of both builds.
| Question | Development build | Production build |
|---|---|---|
| Which file does npm's entry load? | cjs/react-jsx-runtime.development.js, when NODE_ENV isn't production. | cjs/react-jsx-runtime.production.js, when NODE_ENV is production. |
What happens to if (__DEV__) blocks? | They stay, and __DEV__ is true, so warnings and checks run. | They are removed, because __DEV__ is false and the code can never run. |
| Are elements frozen? | Yes. Object.freeze runs on each element and its props. | No. The freezing code was in a __DEV__ block. |
| How do error messages look? | Full sentences, like the Invalid hook call text. | A number and a link to react.dev/errors, to keep the file small. |
| Can you read it? | Yes: names and structure match the source, without comments or types. | Mostly: names stay, but small functions are copied into their callers. |
Reading the source with a debugger
Reading files top to bottom is slow. A faster way is to stop React in the middle of its work and look at who called whom. That list of calls is the call stack (see The call stack), and every Error carries a copy of it as text in its stack property (see The stack trace API). This example reads that text inside App and lists the names, from App down:
import { useState } from "react"; function callers() { Error.stackTraceLimit = 50; const lines = new Error().stack.split("\n"); const names = lines.map((line) => /at (?:Object\.)?([\w$]+) /.exec(line)?.[1] ?? /^(?:Object\.)?([\w$]+)@/.exec(line)?.[1]); return names.slice(names.indexOf("App")).filter(Boolean);} export default function App() { const [count, setCount] = useState(0); const names = callers(); return ( <> <button onClick={() => setCount(count + 1)}>Render again ({count})</button> <ol> {names.map((name, index) => ( <li key={index}>{name}</li> ))} </ol> </> );}Line 4 asks for up to 50 lines of stack, since browsers keep only a few by default. Line 5 makes an Error without throwing it and splits its stack into lines. Line 6 picks each function's name out of a line; Chrome writes at renderWithHooks (…) and Firefox and Safari write renderWithHooks@…, so it tries both. Line 7 keeps the names from App down. Each name is a real function in React's development build, and each one is in the source:
| Name in the stack | File at v19.3.1 | What it does |
|---|---|---|
App | Your file. | Your component, called by React. |
react_stack_bottom_frame | ReactFiberCallUserSpace.js | A one-line wrapper that calls your component in development, so React knows where your code starts. |
renderWithHooks (and renderWithHooksAgain) | ReactFiberHooks.js | Puts the hooks dispatcher in place and calls your component. |
updateFunctionComponent, beginWork | ReactFiberBeginWork.js | Decide what kind of fiber this is and start its work. |
performUnitOfWork, workLoopSync, renderRootSync, performWorkOnRoot | ReactFiberWorkLoop.js | Walk the tree one fiber at a time until the render is done. |
Read from the bottom up, it's the update you met in Render and commit: performWorkOnRoot starts a render, workLoopSync loops over fibers, performUnitOfWork takes one, beginWork sees a function component, and renderWithHooks calls it. The work loop lesson later in this stage reads each of them.
Why is renderWithHooksAgain there, above renderWithHooks? Live examples run in StrictMode, which calls each component twice in development to catch impure code. The list you see comes from the second call. Here is where that happens, in ReactFiberHooks.js:
let children = __DEV__
? callComponentInDEV(Component, props, secondArg)
: Component(props, secondArg);
// …
if (shouldDoubleRenderDEV) {
// In development, components are invoked twice to help detect side effects.
setIsStrictModeForDevtools(true);
try {
children = renderWithHooksAgain(
workInProgress,
Component,
props,
secondArg,
);
} finally {
setIsStrictModeForDevtools(false);
}
}Lines 1 to 3 call your component: through callComponentInDEV in development, which is the react_stack_bottom_frame wrapper, and directly in production. Lines 5 to 18 run only when StrictMode is on, and line 9 calls it again. Look at the bottom of the list, too. The first render ends in performWorkOnRootViaSchedulerTask and performWorkUntilDeadline: the scheduler ran it as a task. Now press Render again. The top of the list stays the same, but the bottom changes: a click's update runs through performSyncWorkOnRoot and processRootScheduleInMicrotask, in ReactFiberRootScheduler.js, because React handles a click's render in a microtask right after your handler.
To step through it yourself, in your own project:
- Put
debugger;on a line inside your component, or click a line number in the browser's Sources panel to set a breakpoint (Debugging in the browser covers both). Reload the page. - When it stops, look at the Call Stack panel. Chrome hides library code by default, as "ignore-listed" frames; right-click the list and choose to show them, and React's functions appear, the same names as above.
- Click a name, like
beginWork, to open React's development file at that line. With Vite it isnode_modules/.vite/deps/react-dom_client.js, built fromreact-dom/cjs/react-dom-client.development.js. Step from there, or search the file for a function name. - When you want the comments and the full code, open the same function at the tag on GitHub; the names match. In the source, skip
if (__DEV__)blocks on a first read, and look up any flag inReactFeatureFlags.js.
Tracing an Invalid hook call to its source
The map pays off when React prints something you don't understand. "Invalid hook call" is a common one, and you've now seen both places that print it. Here is a cart whose Save button calls a hook from its click handler, a bug the Rules of Hooks lesson warns about. Click Save cart.
import { useState } from "react"; export default function App() { const [message, setMessage] = useState("Click to save the cart."); function saveCart() { try { const [saved] = useState(false); setMessage("Saved: " + saved); } catch (error) { setMessage(error.message.split("\n")[0]); } } return ( <> <button onClick={saveCart}>Save cart</button> <p>{message}</p> </> );}Line 8 calls useState while the handler runs. The render finished long ago, so the slot H holds ContextOnlyDispatcher, whose useState is throwInvalidHookError. It throws, line 11 catches the error and shows its first line. Without the try, the error would stop the handler.
The other version of the message comes from resolveDispatcher, when H is null. In a working app, that happens when your component and react-dom use two different copies of react, say because a library bundled its own. react-dom fills H in the copy it imports; your component reads H from the other copy, still null. React prints the message, then dispatcher.useState reads a property of null and throws. How to read the table: each row is one way the message appears.
| What you see | Where it comes from | Usual cause |
|---|---|---|
A console.error, then Cannot read properties of null (reading 'useState') | resolveDispatcher in ReactHooks.js: the slot H is null. | No render is running in this copy of React: a hook called before anything rendered, or a second copy of react that react-dom never fills. |
A thrown Error: Invalid hook call… | throwInvalidHookError in ReactFiberHooks.js: H holds ContextOnlyDispatcher. | A hook called outside a component's body after React has rendered: in an event handler, a timer, or an effect's callback. |
The second row is why the message lists "more than one copy of React" as a reason. To check a real project, run npm ls react: it prints every copy npm installed, and there should be one. Now sort a few functions into the folders that hold them:
useStatecreateElementisValidElementbeginWorkworkLoopSynccreateInstancecommitTextUpdateunstable_scheduleCallbackshouldYieldToHost
Sort each function into the folder of facebook/react where it's defined at v19.3.1.
Common mix-ups
- "The react package renders my components." No. It has no rendering code.
useStateis two lines that pass your call to a dispatcher, and the code that renders ships insidereact-dom. - "My app installs react-reconciler." No. Each renderer copies the reconciler into its own files. The
react-reconcilerpackage on npm is for people who write their own renderer. - "__DEV__ is a variable React checks while my app runs." No. The build replaced it with
trueorfalseand removed the blocks that can never run. Your bundler'sNODE_ENVonly picks which file loads. - "Feature flags are settings I can turn on." No. They are fixed when React is built. A stable release has every
__EXPERIMENTAL__flag off. - "The production build can't be read." No. React 19's production files keep their function names. They lose the warnings and comments, and small functions are copied into their callers.
- "The main branch on GitHub is the code I run." No.
mainchanges every day. Read the tag for your version, herev19.3.1.
Practice exercises
The live exercises show their result as you edit. Use the map, the playgrounds, and the links above; every answer is in a file you've seen.
Without the playground: which folder of facebook/react holds the createRoot your main.jsx imports?
// packages/react-dom/client.js, line 10
export {createRoot, hydrateRoot, version} from './src/client/ReactDOMClient';createRoot comes from react-dom/client, so it's in packages/react-dom. client.js passes it on from src/client, and the function itself is in src/client/ReactDOMRoot.js, at line 171.
Line 3 calls jsx() yourself, with the key 3, a number. What type is item.key? Answer, then press Edit & run to check.
import { jsx } from "react/jsx-runtime";
const item = jsx("li", { children: "Green tea" }, 3);
export default function App() {
return <p>The key is {JSON.stringify(item.key)}, a {typeof item.key}.</p>;
}import { jsx } from "react/jsx-runtime"; const item = jsx("li", { children: "Green tea" }, 3); export default function App() { return <p>The key is {JSON.stringify(item.key)}, a {typeof item.key}.</p>;}jsx() turns the key into a string with "" + maybeKey, in both builds. The page shows The key is "3", a string. That's why key={3} and key="3" count as the same key.
The app has rendered once. In a click handler, is internals.H null? Predict true or false, then press Edit & run and click.
import { useState, __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE as internals } from "react";
export default function App() {
const [answer, setAnswer] = useState("?");
return (
<>
<button onClick={() => setAnswer(String(internals.H === null))}>Is H null now?</button>
<p>In a click handler, H is null: {answer}</p>
</>
);
}import { useState, __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE as internals } from "react"; export default function App() { const [answer, setAnswer] = useState("?"); return ( <> <button onClick={() => setAnswer(String(internals.H === null))}>Is H null now?</button> <p>In a click handler, H is null: {answer}</p> </> );}The button shows false. On the first render, pushDispatcher found null and handed back ContextOnlyDispatcher, which the work loop puts in H when the render ends. The slot is never null again, so a hook outside a render throws a clear error.
Add a Remove last button to the spy example. Click Add lemonade, then Remove last. Which call does the record show?
import { useLayoutEffect, useState } from "react";import { calls } from "./spy.js"; export default function App() { const [items, setItems] = useState(["Tea", "Cake"]); const [log, setLog] = useState([]); useLayoutEffect(() => { if (calls.length > 0) setLog(calls.splice(0)); }); return ( <> <button onClick={() => setItems([...items, "Lemonade"])}>Add lemonade</button> <button onClick={() => setItems(["Mint", ...items])}>Add mint first</button> <button onClick={() => setItems(items.slice(1))}>Remove first</button> <ul> {items.map((name) => ( <li key={name}>{name}</li> ))} </ul> <pre>{log.join("\n")}</pre> </> );}import { useLayoutEffect, useState } from "react";import { calls } from "./spy.js"; export default function App() { const [items, setItems] = useState(["Tea", "Cake"]); const [log, setLog] = useState([]); useLayoutEffect(() => { if (calls.length > 0) setLog(calls.splice(0)); }); return ( <> <button onClick={() => setItems([...items, "Lemonade"])}>Add lemonade</button> <button onClick={() => setItems(["Mint", ...items])}>Add mint first</button> <button onClick={() => setItems(items.slice(1))}>Remove first</button> <button onClick={() => setItems(items.slice(0, -1))}>Remove last</button> <ul> {items.map((name) => ( <li key={name}>{name}</li> ))} </ul> <pre>{log.join("\n")}</pre> </> );}Line 17 is the new button. After Add lemonade and Remove last, the record shows one call, ul.removeChild(<li>Lemonade</li>): the host config's removeChild. Tea and Cake aren't touched, because their keys are still there.
Press Render again in this example. One function near the bottom of the list says, in its name, how React ran this render. Which one?
import { useState } from "react"; function callers() { Error.stackTraceLimit = 50; const lines = new Error().stack.split("\n"); const names = lines.map((line) => /at (?:Object\.)?([\w$]+) /.exec(line)?.[1] ?? /^(?:Object\.)?([\w$]+)@/.exec(line)?.[1]); return names.slice(names.indexOf("App")).filter(Boolean);} export default function App() { const [count, setCount] = useState(0); const names = callers(); return ( <> <button onClick={() => setCount(count + 1)}>Render again ({count})</button> <ol> {names.map((name, index) => ( <li key={index}>{name}</li> ))} </ol> </> );}import { useState } from "react"; function callers() { Error.stackTraceLimit = 50; const lines = new Error().stack.split("\n"); const names = lines.map((line) => /at (?:Object\.)?([\w$]+) /.exec(line)?.[1] ?? /^(?:Object\.)?([\w$]+)@/.exec(line)?.[1]); return names.slice(names.indexOf("App")).filter(Boolean);} export default function App() { const [count, setCount] = useState(0); const names = callers(); return ( <> <button onClick={() => setCount(count + 1)}>Render again ({count})</button> <ol> {names.map((name, index) => ( <li key={index}>{name}</li> ))} </ol> </> );}After a click, the list ends in processRootScheduleInMicrotask, then flushSyncWorkAcrossRoots_impl and performSyncWorkOnRoot above it, all in ReactFiberRootScheduler.js. React queued the click's render as a microtask, so it ran right after your handler finished.
Symbol.for("react.optimistic_key") is the value the enableOptimisticKey branch looks for. In React 19.3.1, what happens when you pass it as a key? Predict, then press Edit & run.
import { jsx } from "react/jsx-runtime";
let result;
try {
jsx("li", { children: "Green tea" }, Symbol.for("react.optimistic_key"));
result = "No error: the experimental branch ran.";
} catch (error) {
result = error.name + ": " + error.message;
}
export default function App() {
return <p>{result}</p>;
}import { jsx } from "react/jsx-runtime"; let result;try { jsx("li", { children: "Green tea" }, Symbol.for("react.optimistic_key")); result = "No error: the experimental branch ran.";} catch (error) { result = error.name + ": " + error.message;} export default function App() { return <p>{result}</p>;}The page shows TypeError: Cannot convert a Symbol value to a string (Firefox words it differently). In the stable build, the optimistic-key branch was removed, so jsx() treats the symbol like any key and tries "" + maybeKey, which throws. In development, React also prints a warning first, that a key must be a string.
Check your understanding
For each question, ask which package or file is doing the work, and when: while React is built, or while your app runs.
Question 1 of 8Which npm package's file holds the code that calls
document.createElementfor your<li>?Choose an answer to see the explanation.
Question 2 of 8This is the whole app, in a fresh preview. What does it show?
Read the code, then predictimport { __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE as internals } from "react"; const atStart = internals.H; export default function App() { return <p>{String(atStart)}</p>; }Choose an answer to see the explanation.
Question 3 of 8These lines are from React's production build. What is
keyafterjsxProd("li", { key: 7 })?Read the code, then predictfunction jsxProd(type, config, maybeKey) { var key = null; void 0 !== maybeKey && (key = "" + maybeKey); void 0 !== config.key && (key = "" + config.key); // … }Choose an answer to see the explanation.
Question 4 of 8Why does
react-reconciler/src/ReactFiberConfig.jscontain onlythrow new Error('This module must be shimmed by a specific renderer.')?Choose an answer to see the explanation.
Question 5 of 8What happens to an
if (__DEV__) { … }block in React's production file?Choose an answer to see the explanation.
Question 6 of 8An app logs "Invalid hook call" with
console.error, then throwsCannot read properties of null (reading 'useState'). Which cause fits best?Choose an answer to see the explanation.
Question 7 of 8In the "Who called my component?" list, why does
renderWithHooksAgainsit betweenreact_stack_bottom_frameandrenderWithHooks?Choose an answer to see the explanation.
Question 8 of 8Where in the repository is the React Compiler?
Choose an answer to see the explanation.
Key takeaways
- facebook/react is a monorepo.
packages/holds React,compiler/holds the React Compiler. Read it at your version's tag, herev19.3.1. reactdescribes UI and passes hook calls on through the slotH. The renderer fills that slot while it renders your component.- The reconciler works out what changed and calls a host config to do it.
react-domcarries the reconciler and the DOM host config, fromreact-dom-bindings, in its own files. - The build replaces
__DEV__,__EXPERIMENTAL__, and the feature flags with fixed values, and removes code that can never run. npm's entry file picks the development or production file byNODE_ENV. - A stack trace, or a breakpoint, shows React's own function names. Look them up at the tag to read the code with its comments.
Remember the one-liner.
react describes, the reconciler decides, the host config does, and the build picks what ships.
Coming next: React elements and the JSX runtime. It opens ReactJSXElement.js and looks at the objects every jsx() call returns, field by field.