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
React InternalsPart 01 · 2 of 18

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.

PackageWhat it containsKnows about fibers?Knows about DOM?
reactjsx/createElement, Component, hook stubs (useState and friends), startTransition, memo, lazy, context objects, ReactSharedInternalsNoNo
react-reconcilerThe fiber work loop, beginWork/completeWork/commit, hooks implementation, lanesYesNo (talks to a host config)
react-domcreateRoot, hydrateRoot, flushSync, portals, plus a bundled copy of the reconciler built against ReactFiberConfigDOM, plus the event systemYes (bundled)Yes
schedulerA priority task queue on top of MessageChannel (see Lanes & Scheduler)NoNo

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 react runs under DOM, Native, ART or a custom renderer. Only the host config changes.
  • Libraries depend only on react. A component library should not pin react-dom, so react has 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

JSX
// 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):

  • children goes into the props object. It is not passed as varargs.
  • key is a separate third argument. The compiler pulls it out, so the runtime does not have to scan props for it.
  • ref stays 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

JavaScript
// 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

JavaScript
// 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:

  • $$typeof is 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 is react.transitional.element, not the old react.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.ref is now a deprecated mirror. The comment says props.ref is the source of truth. In DEV, element.ref is a non-enumerable getter that warns when read.7 For forwardRef-free components in 19, ref arrives in props just like any other prop.
  • DEV elements are frozen. In DEV, Object.freeze(element.props) and Object.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:

JavaScript
// 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

FieldOld nameMeaning
HReactCurrentDispatcherThe hooks dispatcher: an object with useState, useEffect, ... set by the renderer while a component is rendering
AReactCurrentCacheThe async dispatcher (cache / getOwner), set by the work loop
TReactCurrentBatchConfigThe current transition object. startTransition sets it, and requestUpdateLane reads it
SCallback run when a transition starts (used by the reconciler for async actions)
GGesture transition hook (behind enableGestureTransition)
DEV: actQueue, thrownErrors, getCurrentStack, ...testing (act) and debugging support

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

JavaScript
// 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

JavaScript
// 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 = ContextOnlyDispatcher

HooksDispatcherOnMount.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

That explains the three causes listed in the famous "Invalid hook call" error:20

  1. Hook called outside render. H is null (no render has started yet) or ContextOnlyDispatcher (between components). You get a DEV console.error or a thrown error.
  2. Mismatched React and renderer versions. The renderer's dispatcher lacks a method the react stub calls.
  3. Two copies of React. Your component imported react copy B, but react-dom sets H on copy A's internals. Copy B's H stays null. 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

JavaScript
// 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:

Host-config exportUsed byPage
createInstance, createTextInstance, appendInitialChild, finalizeInitialChildren, shouldSetTextContentcompleteWork builds detached DOMcompleteWork
prepareForCommit, resetAfterCommit, appendChildToContainer, commitUpdate, commitMount, removeChildcommit mutationsCommit Phase
scheduleMicrotask, scheduleTimeoutroot schedulerroot.render
resolveUpdatePriorityrequestUpdateLaneLanes & Scheduler
supportsMutation = true, isPrimaryRenderer = trueselects the mutation code paths

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

  1. import React from 'react' evaluates ReactSharedInternalsClient: {H: null, A: null, T: null, S: null} (plus DEV fields).11
  2. import { createRoot } from 'react-dom/client' evaluates the bundled reconciler and the DOM host config. The event system builds its allNativeEvents registry. The Scheduler picks MessageChannel (browser) or setImmediate (Node) for its host callback (see Lanes & Scheduler).
  3. Your module graph evaluates. Every JSX expression at module top level (rare) runs jsx() immediately. Component bodies do not run yet.
  4. createRoot(container) builds the FiberRoot and the HostRoot fiber and attaches listeners. See createRoot.
  5. root.render(<App />) creates one element (<App />), enqueues it as an update on the HostRoot, and schedules work. See root.render. App itself 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:

JavaScript
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: react knows nothing about fibers or the DOM, so one component model runs under many renderers.
  • jsxProd: key arrives as a separate argument, ref stays in props, and the compiler-created object is reused as props when no key is spread in.
  • React elements: immutable, recreated every render, tagged with a $$typeof Symbol JSON cannot contain; fibers, not elements, have identity.
  • ReactSharedInternals: the one mutable object both sides share, with slots such as H (hooks dispatcher) and T (current transition).
  • Hook stubs: useState from react reads ReactSharedInternals.H and forwards the call.
  • renderWithHooks: installs HooksDispatcherOnMount or HooksDispatcherOnUpdate before your component runs, then H is reset to ContextOnlyDispatcher.
  • "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) is useMemoCache and goes through the same H path.

← Prev · Index · Next →

Footnotes

  1. react-dom dependencies — packages/react-dom/package.json#L19-L24 (opens in a new tab) ↩

  2. react-dom/client bundle definition — scripts/rollup/bundles.js#L193-L200 (opens in a new tab) ↩

  3. getDependencies treats deps and peerDeps as external — scripts/rollup/modules.js#L49-L60 (opens in a new tab) ↩

  4. React 19 Upgrade Guide (new JSX transform required, ReactDOM.render removed, element.ref deprecated, StrictMode memo reuse) — https://react.dev/blog/2024/04/25/react-19-upgrade-guide (opens in a new tab) ↩ ↩2

  5. jsx/jsxs/jsxDEV exports — packages/react/src/jsx/ReactJSX.js#L17-L28 (opens in a new tab) ↩

  6. jsxProd — packages/react/src/jsx/ReactJSXElement.js#L291-L348 (opens in a new tab) ↩ ↩2

  7. ReactElement (element shape, ref deprecation, DEV freeze) — packages/react/src/jsx/ReactJSXElement.js#L170-L283 (opens in a new tab) ↩ ↩2 ↩3

  8. REACT_ELEMENT_TYPE = Symbol.for('react.transitional.element') — packages/shared/ReactSymbols.js#L14-L18 (opens in a new tab) ↩

  9. Internals exported from react — packages/react/src/ReactClient.js#L110 (opens in a new tab) ↩

  10. shared/ReactSharedInternals reads React's export — packages/shared/ReactSharedInternals.js#L10-L15 (opens in a new tab) ↩

  11. SharedStateClient type and initial object — packages/react/src/ReactSharedInternalsClient.js#L24-L80 (opens in a new tab) ↩ ↩2

  12. ReactDOMSharedInternals (d, p) — packages/react-dom/src/ReactDOMSharedInternals.js#L46-L50 (opens in a new tab) ↩

  13. resolveDispatcher and useState stub — packages/react/src/ReactHooks.js#L24-L71 (opens in a new tab) ↩

  14. renderWithHooks dispatcher selection — packages/react-reconciler/src/ReactFiberHooks.js#L505-L634 (opens in a new tab) ↩

  15. HooksDispatcherOnMount (useState: mountState) — packages/react-reconciler/src/ReactFiberHooks.js#L3926-L3952 (opens in a new tab) ↩

  16. finishRenderingHooks resets H — packages/react-reconciler/src/ReactFiberHooks.js#L636-L660 (opens in a new tab) ↩

  17. ContextOnlyDispatcher — packages/react-reconciler/src/ReactFiberHooks.js#L3898-L3924 (opens in a new tab) ↩

  18. pushDispatcher / popDispatcher — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2489-L2504 (opens in a new tab) ↩

  19. pushDispatcher call sites in renderRootSync / renderRootConcurrent — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2630 (opens in a new tab) and #L2782 (opens in a new tab) ↩

  20. throwInvalidHookError — packages/react-reconciler/src/ReactFiberHooks.js#L445-L454 (opens in a new tab) ↩

  21. StrictMode double render via renderWithHooksAgain — packages/react-reconciler/src/ReactFiberHooks.js#L565-L629 (opens in a new tab) ↩

  22. Host config placeholder — packages/react-reconciler/src/ReactFiberConfig.js#L12-L20 (opens in a new tab) ↩

  23. Rollup fork for ReactFiberConfig.js — scripts/rollup/forks.js#L235-L263 (opens in a new tab) ↩

  24. DOM fork re-exports ReactFiberConfigDOM — packages/react-reconciler/src/forks/ReactFiberConfig.dom.js (opens in a new tab) ↩

  25. DOM host config functions: prepareForCommit L424, resetAfterCommit L457, createInstance L534, appendInitialChild L695, finalizeInitialChildren L703, shouldSetTextContent L744, createTextInstance L757, isPrimaryRenderer L827, scheduleTimeout L832, scheduleMicrotask L871, supportsMutation L889, commitMount L891, commitUpdate L999, appendChildToContainer L1082, removeChild L1213 — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js (opens in a new tab) ↩

  26. react/compiler-runtime → useMemoCache as c — packages/react/src/ReactCompilerRuntime.js#L10 (opens in a new tab) ↩

  27. useMemoCache stub in react — packages/react/src/ReactHooks.js#L212-L216 (opens in a new tab) ↩

  28. Reconciler useMemoCache — packages/react-reconciler/src/ReactFiberHooks.js#L1181-L1263 (opens in a new tab) ↩

  29. __COMPILER_RUNTIME export — packages/react/src/ReactClient.js#L66 (opens in a new tab) and #L111 (opens in a new tab) ↩

  30. react-compiler-runtime c re-export / polyfill — compiler/packages/react-compiler-runtime/src/index.ts#L19-L40 (opens in a new tab) ↩