Bootstrap - Packages, JSX and the Dispatcher
react is a small package. It builds plain element objects (jsx()) and holds one shared mutable object, ReactSharedInternals, with slots like H (the hooks dispatcher) and T (the…
- Published
- Reading time
- 11 min read
The package split, and why it is shaped this way
The first surprise is that the code you import from is not the code that does the work.
react-dom's package.json lists only scheduler as a dependency and react as a peer dependency.1 The reconciler is not a runtime dependency. It is inlined into the react-dom bundle at build time. The Rollup bundle for react-dom/client keeps react and react-dom external2, and the build marks all dependencies and peerDependencies of a package as external too.3 So react-dom-client.production.js contains a full private copy of the reconciler. react-native and react-three-fiber (via the public react-reconciler npm package) each ship their own copy.
Why split it like this?
- One component model, many renderers. A component written against
reactruns under DOM, Native, ART or a custom renderer. Only the host config changes. - Libraries depend only on
react. A component library should not pinreact-dom, soreacthas to be importable with no renderer present. - A single source of truth for "who is rendering right now". That is the shared internals object below.
The JSX transform
Since React 17 the "automatic runtime" compiles JSX into calls to react/jsx-runtime, not React.createElement. React 19 requires it, because ref-as-prop and the faster element creation depend on it.4
// source
<Button kind="primary" key={id} ref={btnRef}>Save</Button>
// output (automatic runtime, production)
import { jsx as _jsx } from "react/jsx-runtime";
_jsx(Button, { kind: "primary", ref: btnRef, children: "Save" }, id);Notice what changed compared with createElement(type, props, ...children):
childrengoes into the props object. It is not passed as varargs.keyis a separate third argument. The compiler pulls it out, so the runtime does not have to scan props for it.refstays in props. That is the React 19 "ref as a prop" change.
react/jsx-runtime exports jsx and jsxs (static children; it differs only in DEV key validation). In production both point to jsxProd. In DEV, jsx/jsxs are dev wrappers, and jsxDEV comes from react/jsx-dev-runtime.5
jsxProd and the element shape
jsxProd does very little work, and that is on purpose: it runs for every element in every render.6
// packages/react/src/jsx/ReactJSXElement.js (simplified)
export function jsxProd(type, config, maybeKey) {
let key = null;
if (maybeKey !== undefined) key = '' + maybeKey;
if (hasValidKey(config)) key = '' + config.key; // <div {...props} /> with key spread in
let props;
if (!('key' in config)) {
props = config; // reuse the compiler-created object as props: zero copy
} else {
props = {};
for (const propName in config) if (propName !== 'key') props[propName] = config[propName];
}
return ReactElement(type, key, props, getOwner(), undefined, undefined);
}The "reuse config as props" fast path is only safe because jsx is a compiler target: the compiler always passes a fresh object literal. createElement cannot assume that, so it always copies. The source comment says exactly this.6
ReactElement builds the object itself:7
// production shape
{
$$typeof: REACT_ELEMENT_TYPE, // Symbol.for('react.transitional.element')
type, // 'div' | function | class | memo/lazy/context object...
key, // string | null
ref, // mirrors props.ref (deprecated accessor)
props, // includes children and ref
}Details worth knowing:
$$typeofis a Symbol on purpose. JSON cannot contain Symbols. So a server response that someone tricks into rendering as{type: 'div', props: {dangerouslySetInnerHTML: ...}}is not treated as an element. That is the old XSS defence. In React 19 the symbol isreact.transitional.element, not the oldreact.element.8 Elements from a copy of React 18 bundled into the same page are therefore rejected ("Objects are not valid as a React child"). This is often the first sign of a duplicate-React problem.element.refis now a deprecated mirror. The comment saysprops.refis the source of truth. In DEV,element.refis a non-enumerable getter that warns when read.7 ForforwardRef-free components in 19,refarrives inpropsjust like any other prop.- DEV elements are frozen. In DEV,
Object.freeze(element.props)andObject.freeze(element)run, and extra non-enumerable fields (_store,_debugInfo,_debugStack,_debugTask) are attached.7 Mutating props "works" in production and throws in DEV. Don't do it. _owner(DEV only in this version) records which component created the element. It powers owner stacks in error messages.
Elements are immutable descriptions, created fresh on every render. They do not have identity across renders. Fibers do. Reconciliation (see beginWork & Reconciliation) is the step that maps "this element at this position/key" onto "that existing fiber".
ReactSharedInternals: the switchboard
react exports one oddly named object:
// packages/react/src/ReactClient.js
export { ReactSharedInternals as __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE, ... }9 Renderers import it through shared/ReactSharedInternals, which is literally React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.10 Because the renderer reads it from the react module it was given, both sides share the same object at runtime. The fields:11
Short one-letter keys are used because these properties are read on every hook call and survive minification as property names. Old long names like ReactCurrentDispatcher.current cost bytes and an extra hop.
react-dom has its own internals too. ReactDOMSharedInternals.p holds the current update priority (used by Event System and requestUpdateLane), and .d holds the DOM resource dispatcher for preload and the like.12
How useState from react reaches the reconciler
In react, a hook is just a forwarding stub:13
// packages/react/src/ReactHooks.js (simplified)
function resolveDispatcher() {
const dispatcher = ReactSharedInternals.H;
// DEV: if null, console.error('Invalid hook call...')
return dispatcher;
}
export function useState(initialState) {
const dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}The reconciler installs the real implementation in renderWithHooks, just before it calls your function:14
// packages/react-reconciler/src/ReactFiberHooks.js (prod branch, simplified)
ReactSharedInternals.H =
current === null || current.memoizedState === null
? HooksDispatcherOnMount // useState -> mountState
: HooksDispatcherOnUpdate; // useState -> updateState
let children = Component(props, secondArg);
// ...
finishRenderingHooks(current, workInProgress, Component); // sets H = ContextOnlyDispatcherHooksDispatcherOnMount.useState is mountState.15 When the component returns, finishRenderingHooks resets H to ContextOnlyDispatcher, where every hook except readContext/use is throwInvalidHookError.1617 The work loop also wraps the whole render in pushDispatcher/popDispatcher, which sets H = ContextOnlyDispatcher for the duration of renderRootSync/renderRootConcurrent.1819
sequenceDiagram
participant App as Your component
participant R as react (ReactHooks)
participant SI as ReactSharedInternals.H
participant Rec as reconciler (ReactFiberHooks)
Rec->>SI: H = HooksDispatcherOnMount / OnUpdate
Rec->>App: Component(props)
App->>R: useState(0)
R->>SI: read H
SI-->>Rec: H.useState(0) = mountState / updateState
Rec-->>App: [state, setState]
App-->>Rec: return JSX elements
Rec->>SI: H = ContextOnlyDispatcherThat explains the three causes listed in the famous "Invalid hook call" error:20
- Hook called outside render.
Hisnull(no render has started yet) orContextOnlyDispatcher(between components). You get a DEVconsole.erroror a thrown error. - Mismatched React and renderer versions. The renderer's dispatcher lacks a method the
reactstub calls. - Two copies of React. Your component imported
reactcopy B, butreact-domsetsHon copy A's internals. Copy B'sHstaysnull. Nothing is "wrong" with the hook. The two switchboards just never meet.
StrictMode double render lives here too
In DEV, if the fiber has StrictLegacyMode, renderWithHooks calls renderWithHooksAgain a second time after the first call.21 The source comment is precise about a non-obvious detail: the second call reuses hook state from the first, so useMemo does not recompute on the second pass. To still catch impure memo functions, user functions are double-invoked during the first pass instead. The React 19 upgrade guide describes the visible result: useMemo/useCallback reuse the first render's result during the second render.4 This is DEV only. Production never double-renders, so "my effect ran twice" bugs exist only on your machine, by design.
The host config: how the reconciler stays DOM-agnostic
The reconciler imports from ./ReactFiberConfig, but that file only throws:22
// packages/react-reconciler/src/ReactFiberConfig.js
throw new Error('This module must be shimmed by a specific renderer.');At build time, Rollup's fork mechanism swaps it for forks/ReactFiberConfig.<renderer>.js.23 For DOM, that fork re-exports react-dom-bindings/src/client/ReactFiberConfigDOM.24 The host config is a flat list of functions and constants the reconciler calls. A few you'll meet later:
Line numbers for these are in the footnotes.25 Because the host config is resolved at build time and not injected at runtime, calls into it are direct function calls the minifier can inline. A runtime "renderer interface" object would add a dynamic dispatch to every DOM operation.
Putting it in order: what is loaded before anything renders
import React from 'react'evaluatesReactSharedInternalsClient:{H: null, A: null, T: null, S: null}(plus DEV fields).11import { createRoot } from 'react-dom/client'evaluates the bundled reconciler and the DOM host config. The event system builds itsallNativeEventsregistry. The Scheduler picksMessageChannel(browser) orsetImmediate(Node) for its host callback (see Lanes & Scheduler).- Your module graph evaluates. Every JSX expression at module top level (rare) runs
jsx()immediately. Component bodies do not run yet. createRoot(container)builds the FiberRoot and the HostRoot fiber and attaches listeners. See createRoot.root.render(<App />)creates one element (<App />), enqueues it as an update on the HostRoot, and schedules work. See root.render.Appitself has still not been called. That happens in the Work Loop.
With the React Compiler
The compiler does not change any of the bootstrap machinery. It rewrites component bodies. The one bootstrap-level touch point is an extra import in compiled modules:
import { c as _c } from "react/compiler-runtime";
function Greeting(props) {
const $ = _c(2); // allocate/read a 2-slot memo cache for this fiber
// ...
}In React 19, react/compiler-runtime re-exports useMemoCache as c.26 useMemoCache in react is a stub that forwards to the dispatcher like every other hook.27 So _c(n) runs through the same ReactSharedInternals.H path described above and ends in the reconciler's useMemoCache, which keeps the cache array on the fiber's update queue.28 React itself exposes the runtime as __COMPILER_RUNTIME.29 The standalone react-compiler-runtime npm package re-exports React.__COMPILER_RUNTIME.c when present and otherwise polyfills it with a useMemo of a sentinel-filled array, for React 17/18.30 How the compiler decides what goes into those slots is covered in React Compiler, and the runtime effect in With vs Without Compiler.
Summary
The code you import is not the code that does the work. react builds throwaway element objects and owns one shared switchboard, ReactSharedInternals. react-dom carries a private copy of the reconciler, compiled against the DOM host config, and plugs its own dispatcher into that switchboard right before calling your component. So a hook is a forwarding stub whose behaviour depends on what the renderer put in H. Nothing has rendered yet: createRoot builds the root objects next.
Key takeaways
- Package split:
reactknows nothing about fibers or the DOM, so one component model runs under many renderers. jsxProd:keyarrives as a separate argument,refstays in props, and the compiler-created object is reused aspropswhen nokeyis spread in.- React elements: immutable, recreated every render, tagged with a
$$typeofSymbol JSON cannot contain; fibers, not elements, have identity. ReactSharedInternals: the one mutable object both sides share, with slots such asH(hooks dispatcher) andT(current transition).- Hook stubs:
useStatefromreactreadsReactSharedInternals.Hand forwards the call. renderWithHooks: installsHooksDispatcherOnMountorHooksDispatcherOnUpdatebefore your component runs, thenHis reset toContextOnlyDispatcher.- "Invalid hook call": a hook outside render, mismatched versions, or two copies of React whose switchboards never meet.
- Host config: swapped in at build time, so DOM operations are direct function calls, not runtime dispatch.
- React Compiler:
_c(n)isuseMemoCacheand goes through the sameHpath.
Footnotes
-
react-domdependencies — packages/react-dom/package.json#L19-L24 (opens in a new tab) ↩ -
react-dom/clientbundle definition — scripts/rollup/bundles.js#L193-L200 (opens in a new tab) ↩ -
getDependenciestreats deps and peerDeps as external — scripts/rollup/modules.js#L49-L60 (opens in a new tab) ↩ -
React 19 Upgrade Guide (new JSX transform required,
ReactDOM.renderremoved,element.refdeprecated, StrictMode memo reuse) — https://react.dev/blog/2024/04/25/react-19-upgrade-guide (opens in a new tab) ↩ ↩2 -
jsx/jsxs/jsxDEVexports — packages/react/src/jsx/ReactJSX.js#L17-L28 (opens in a new tab) ↩ -
jsxProd— packages/react/src/jsx/ReactJSXElement.js#L291-L348 (opens in a new tab) ↩ ↩2 -
ReactElement(element shape, ref deprecation, DEV freeze) — packages/react/src/jsx/ReactJSXElement.js#L170-L283 (opens in a new tab) ↩ ↩2 ↩3 -
REACT_ELEMENT_TYPE = Symbol.for('react.transitional.element')— packages/shared/ReactSymbols.js#L14-L18 (opens in a new tab) ↩ -
Internals exported from
react— packages/react/src/ReactClient.js#L110 (opens in a new tab) ↩ -
SharedStateClienttype and initial object — packages/react/src/ReactSharedInternalsClient.js#L24-L80 (opens in a new tab) ↩ ↩2 -
ReactDOMSharedInternals(d,p) — packages/react-dom/src/ReactDOMSharedInternals.js#L46-L50 (opens in a new tab) ↩ -
resolveDispatcheranduseStatestub — packages/react/src/ReactHooks.js#L24-L71 (opens in a new tab) ↩ -
renderWithHooksdispatcher selection — packages/react-reconciler/src/ReactFiberHooks.js#L505-L634 (opens in a new tab) ↩ -
HooksDispatcherOnMount(useState: mountState) — packages/react-reconciler/src/ReactFiberHooks.js#L3926-L3952 (opens in a new tab) ↩ -
finishRenderingHooksresetsH— packages/react-reconciler/src/ReactFiberHooks.js#L636-L660 (opens in a new tab) ↩ -
ContextOnlyDispatcher— packages/react-reconciler/src/ReactFiberHooks.js#L3898-L3924 (opens in a new tab) ↩ -
pushDispatcher/popDispatcher— packages/react-reconciler/src/ReactFiberWorkLoop.js#L2489-L2504 (opens in a new tab) ↩ -
pushDispatchercall sites inrenderRootSync/renderRootConcurrent— packages/react-reconciler/src/ReactFiberWorkLoop.js#L2630 (opens in a new tab) and #L2782 (opens in a new tab) ↩ -
throwInvalidHookError— packages/react-reconciler/src/ReactFiberHooks.js#L445-L454 (opens in a new tab) ↩ -
StrictMode double render via
renderWithHooksAgain— packages/react-reconciler/src/ReactFiberHooks.js#L565-L629 (opens in a new tab) ↩ -
Host config placeholder — packages/react-reconciler/src/ReactFiberConfig.js#L12-L20 (opens in a new tab) ↩
-
Rollup fork for
ReactFiberConfig.js— scripts/rollup/forks.js#L235-L263 (opens in a new tab) ↩ -
DOM fork re-exports
ReactFiberConfigDOM— packages/react-reconciler/src/forks/ReactFiberConfig.dom.js (opens in a new tab) ↩ -
DOM host config functions:
prepareForCommitL424,resetAfterCommitL457,createInstanceL534,appendInitialChildL695,finalizeInitialChildrenL703,shouldSetTextContentL744,createTextInstanceL757,isPrimaryRendererL827,scheduleTimeoutL832,scheduleMicrotaskL871,supportsMutationL889,commitMountL891,commitUpdateL999,appendChildToContainerL1082,removeChildL1213 — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js (opens in a new tab) ↩ -
react/compiler-runtime→useMemoCache as c— packages/react/src/ReactCompilerRuntime.js#L10 (opens in a new tab) ↩ -
useMemoCachestub inreact— packages/react/src/ReactHooks.js#L212-L216 (opens in a new tab) ↩ -
Reconciler
useMemoCache— packages/react-reconciler/src/ReactFiberHooks.js#L1181-L1263 (opens in a new tab) ↩ -
__COMPILER_RUNTIMEexport — packages/react/src/ReactClient.js#L66 (opens in a new tab) and #L111 (opens in a new tab) ↩ -
react-compiler-runtimecre-export / polyfill — compiler/packages/react-compiler-runtime/src/index.ts#L19-L40 (opens in a new tab) ↩