createRoot - FiberRoot and HostRoot

createRoot(container) renders nothing. It checks the container, builds two linked objects, and attaches every event listener React will ever need to the container. The two objects…

Published
Reading time
8 min read
React InternalsPart 02 · 3 of 18

The call chain

Step 1: validation and options

createRoot lives in react-dom/src/client/ReactDOMRoot.js and is exported through react-dom/client.12 The first line is isValidContainer, which accepts an Element, a Document or a DocumentFragment. Unless disableCommentsAsDOMContainers is set, it also accepts a comment node with the exact text ' react-mount-point-unstable '.3 Anything else throws Target container is not a DOM element.. In practice this happens when document.getElementById('root') returns null because the script ran before the DOM was parsed.

warnIfReactDOMContainerInDEV then warns in DEV if the container is already marked as a root, i.e. you called createRoot twice on the same container (or on one previously passed to legacy ReactDOM.render).4 After that the options are copied into local variables.1 Two DEV-only checks are worth knowing:

  • passing {hydrate: true} warns you to use hydrateRoot,
  • passing a JSX element as the options argument (createRoot(el, <App/>), a leftover habit from the old ReactDOM.render(el, container) argument order) prints a helpful error.

concurrentUpdatesByDefaultOverride is hardcoded to false, and the reconciler comment says it is ignored.5 Since disableLegacyMode is true in this build, every root is a ConcurrentRoot.6

Step 2: createContainer → createFiberRoot

createContainer is a thin adapter. It fixes hydrate = false and initialChildren = null, calls createFiberRoot, and registers the default transition indicator.5 hydrateRoot uses the sibling createHydrationContainer. That one does enqueue an initial update right away, because hydration has to match server HTML.9

createFiberRoot is where the structure is built:10

JavaScript
// packages/react-reconciler/src/ReactFiberRoot.js (simplified)
const root = new FiberRootNode(containerInfo, tag, hydrate, identifierPrefix,
                               onUncaughtError, onCaughtError, onRecoverableError, ...);

// Cyclic construction. This cheats the type system right now because stateNode is any.
const uninitializedFiber = createHostRootFiber(tag, isStrictMode);
root.current = uninitializedFiber;
uninitializedFiber.stateNode = root;

const initialCache = createCache();
retainCache(initialCache);
root.pooledCache = initialCache;
retainCache(initialCache);

uninitializedFiber.memoizedState = { element: initialChildren, isDehydrated: hydrate, cache: initialCache };
initializeUpdateQueue(uninitializedFiber);
return root;

FiberRootNode: the fields that matter

The FiberRoot is not a fiber. It is a plain object with a different shape. It holds per-tree state that would be wasteful to keep on every fiber.11

FieldMeaningWho reads/writes it
tagConcurrentRoot (always, with disableLegacyMode)
containerInfothe DOM container you passedhost config, commit (appendChildToContainer)
currentthe current HostRoot fiber. Flipped to the finished WIP tree on every commitwork loop, commit (root.current = finishedWork)12
pendingLanesbitmask of lanes with unprocessed updatesmarkRootUpdated sets bits, markRootFinished clears them
suspendedLanes, pingedLanes, warmLanes, expiredLanesSuspense and starvation bookkeepinggetNextLanes, markStarvedLanesAsExpired
expirationTimesa LaneMap (31 slots) of timestamps, for starvationmarkStarvedLanesAsExpired
entangledLanes, entanglementslanes that must render together (transitions, useSyncExternalStore)
callbackNode, callbackPrioritythe Scheduler task currently scheduled for this root, and its laneroot scheduler
nextlink in the scheduler's linked list of roots with pending workensureRootIsScheduled
timeoutHandle, cancelPendingCommitdelayed/suspended commit handles
context, pendingContextlegacy context for the root (always empty for createRoot)updateContainerImpl
pooledCache, pooledCacheLanes<Cache>/use cache for newly mounting boundaries
identifierPrefixprefix for useId
onUncaughtError, onCaughtError, onRecoverableErroryour error callbacks from optionscommit/error handling
hiddenUpdatesupdates deferred inside hidden Offscreen trees
incompleteTransitions, formStatetransition tracing, form actions

All the lane fields start as NoLanes, and callbackNode starts as null.11 A few of these fields only exist behind feature flags (indicatorLanes, transitionTypes, pendingGestures). The constructor keeps the object shape stable by assigning them conditionally inside the constructor, never later.

createHostRootFiber

JavaScript
// packages/react-reconciler/src/ReactFiber.js (simplified)
export function createHostRootFiber(tag, isStrictMode) {
  let mode = ConcurrentMode;              // disableLegacyMode || tag === ConcurrentRoot
  if (isStrictMode === true) mode |= StrictLegacyMode | StrictEffectsMode;
  if (__DEV__ || (enableProfilerTimer && isDevToolsPresent)) mode |= ProfileMode;
  return createFiber(HostRoot, null, null, mode);
}

13 The HostRoot fiber has tag = HostRoot (3), type = null, pendingProps = null and key = null.14 Its mode bits are inherited by every fiber created below it. Children copy returnFiber.mode when they are created, which is how ConcurrentMode reaches every node. unstable_strictMode: true in createRoot options sets strict mode for the whole tree. A <StrictMode> element only sets the bits from that subtree down (createFiberFromTypeAndProps adds StrictLegacyMode | StrictEffectsMode for REACT_STRICT_MODE_TYPE).15

Note that no alternate is created yet. The WIP copy of the HostRoot is made lazily by createWorkInProgress(root.current, null) in prepareFreshStack when the first render starts.16 See Fiber for double buffering.

The HostRoot's state and update queue

The HostRoot is unusual: its "state" is {element, isDehydrated, cache}, and "rendering the root" means processing its update queue to compute a new element. initializeUpdateQueue attaches a class-style queue:17

JavaScript
fiber.updateQueue = {
  baseState: fiber.memoizedState,      // {element: null, isDehydrated: false, cache}
  firstBaseUpdate: null, lastBaseUpdate: null,
  shared: { pending: null, lanes: NoLanes, hiddenCallbacks: null },
  callbacks: null,
};

This is the same queue structure class components use for this.setState. That is why root.render enqueues an Update with payload: {element} (see root.render). updateHostRoot in beginWork then runs processUpdateQueue and reads nextState.element as the children to reconcile.18 Hooks use a different queue shape (see Hooks on Mount).

After the first commit, HR.child points to the App fiber, and HR.alternate points to the other HostRoot, the one that was the WIP tree. Every commit flips FR.current between the two HostRoot fibers. The stateNode of both points back to the same FR.

Step 3: markContainerAsRoot

JavaScript
node['__reactContainer$' + randomKey] = hostRoot;

randomKey is generated once per copy of react-dom with Math.random().toString(36).slice(2).1920 It is used for:

  • Dev warnings. warnIfReactDOMContainerInDEV uses it (via isContainerMarkedAsRoot) to detect "you already called createRoot on this container".4
  • Event dispatch. When a native event fires, getClosestInstanceFromNode walks DOM ancestors looking for __reactFiber$ / __reactContainer$ keys to find the target fiber (see Event System).21
  • Isolation between copies. The random suffix means two copies of React DOM on one page (micro-frontends) never read each other's internal keys.

Note that it stores root.current at creation time. Since the current pointer flips, code that needs the current tree goes through stateNode.current, not this stored fiber.

Step 4: listenToAllSupportedEvents

listenToAllSupportedEvents(rootContainerElement) runs once per container. It is guarded by a listeningMarker property.22 For each native event in allNativeEvents, it attaches a capture-phase listener and, unless the event is in nonDelegatedEvents (things like scroll, load and media events that don't bubble), also a bubble-phase listener on the container. selectionchange gets attached to the document.

Why do this eagerly, before anything renders?

  • Delegation at the root, not document. Since React 17, listeners go on the root container. This lets several React roots, or React versions, live on one page, and e.stopPropagation() works the way people expect against non-React handlers on document.
  • No per-element listeners. Adding an onClick later costs nothing at the DOM level. The props are just read at dispatch time via __reactProps$.
  • Correct priority from the first event. The listener wrapper (createEventListenerWrapperWithPriority) sets ReactDOMSharedInternals.p based on the event type before calling into React. That is how a click handler's setState ends up on SyncLane (see Lanes & Scheduler, setState).

The full mechanics are on Event System.

Step 5: the ReactDOMRoot object

JavaScript
function ReactDOMRoot(internalRoot) { this._internalRoot = internalRoot; }
ReactDOMRoot.prototype.render = function (children) { /* ... */ updateContainer(children, root, null, null); };
ReactDOMRoot.prototype.unmount = function () { /* ... */ updateContainerSync(null, root, null, null); flushSyncWork(); unmarkContainerAsRoot(container); };

23 The public object is deliberately thin: one field, two prototype methods (ReactDOMHydrationRoot shares them). Points worth noticing:

  • render no longer accepts a callback. In DEV, passing one logs "does not support the second callback argument... use useEffect()". Passing the container again logs a similar error.
  • unmount is synchronous. It uses updateContainerSync (SyncLane) followed by flushSyncWork(), and in DEV it warns if called during a render. It sets _internalRoot = null first, so a second render after unmount throws Cannot update an unmounted root..

With the React Compiler

Nothing here changes. createRoot, the FiberRoot and the HostRoot fiber are reconciler and renderer code. The compiler only rewrites component bodies and never touches react-dom. The only setup difference you might see is that compiled components call _c(n) on their first render. That allocates the memo cache on their own fiber's update queue, not on the root (see React Compiler).

Summary

createRoot sets the stage and renders nothing. It validates the container, builds a FiberRoot and a HostRoot fiber that point at each other, marks the container, attaches every event listener, and returns a thin ReactDOMRoot. The FiberRoot is the single, un-double-buffered owner of per-tree state (lanes, the scheduled callback, the container). The HostRoot fiber tops the fiber tree, and its class-style update queue is where root.render enqueues an {element} update in the next part.

Key takeaways

  • isValidContainer: anything but an Element, Document or DocumentFragment throws Target container is not a DOM element., typically a null from getElementById.
  • ConcurrentRoot only: with disableLegacyMode, every root is concurrent and ReactDOM.render is gone, so automatic batching is unconditional.
  • createFiberRoot: builds the cycle, root.current is the HostRoot and hostRootFiber.stateNode is the root.
  • FiberRootNode: not a fiber, a plain object for per-tree state such as pendingLanes and callbackNode.
  • createHostRootFiber: sets mode bits every fiber below inherits; no alternate exists until prepareFreshStack.
  • initializeUpdateQueue: the class-component queue shape, so rendering the root means processing it to compute a new element.
  • markContainerAsRoot: a randomly suffixed __reactContainer$ key used by DEV warnings, event dispatch and isolation between React copies.
  • listenToAllSupportedEvents: eager capture and bubble listeners on the container, so later onClick props cost nothing at the DOM level.
  • ReactDOMRoot: one field, two methods; render takes no callback and unmount is synchronous.

← Prev · Index · Next →

Footnotes

  1. createRoot — packages/react-dom/src/client/ReactDOMRoot.js#L171-L262 (opens in a new tab) ↩ ↩2

  2. react-dom/client exports — packages/react-dom/src/client/ReactDOMClient.js#L52 (opens in a new tab) ↩

  3. isValidContainer — packages/react-dom-bindings/src/client/ReactDOMContainer.js#L19-L29 (opens in a new tab) ↩

  4. warnIfReactDOMContainerInDEV — packages/react-dom/src/client/ReactDOMRoot.js#L363-L380 (opens in a new tab) ↩ ↩2

  5. createContainer — packages/react-reconciler/src/ReactFiberReconciler.js#L235-L280 (opens in a new tab) ↩ ↩2

  6. disableLegacyMode = true — packages/shared/ReactFeatureFlags.js#L205 (opens in a new tab) ↩

  7. react-dom main exports — packages/react-dom/index.js#L10-L26 (opens in a new tab) ↩

  8. React 19 Upgrade Guide, "Removed: ReactDOM.render" — https://react.dev/blog/2024/04/25/react-19-upgrade-guide (opens in a new tab) ↩

  9. createHydrationContainer — packages/react-reconciler/src/ReactFiberReconciler.js#L282-L351 (opens in a new tab) ↩

  10. createFiberRoot — packages/react-reconciler/src/ReactFiberRoot.js#L157-L236 (opens in a new tab) ↩

  11. FiberRootNode constructor — packages/react-reconciler/src/ReactFiberRoot.js#L50-L155 (opens in a new tab) ↩ ↩2

  12. root.current = finishedWork in commit — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4047-L4051 (opens in a new tab) ↩

  13. createHostRootFiber — packages/react-reconciler/src/ReactFiber.js#L529-L551 (opens in a new tab) ↩

  14. HostRoot = 3 — packages/react-reconciler/src/ReactWorkTags.js#L46 (opens in a new tab) ↩

  15. <StrictMode> sets mode bits — packages/react-reconciler/src/ReactFiber.js#L602-L608 (opens in a new tab) ↩

  16. prepareFreshStack creates the root WIP — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2254-L2258 (opens in a new tab) ↩

  17. initializeUpdateQueue — packages/react-reconciler/src/ReactFiberClassUpdateQueue.js#L176-L189 (opens in a new tab) ↩

  18. updateHostRoot — packages/react-reconciler/src/ReactFiberBeginWork.js#L1831 (opens in a new tab) ↩

  19. internal DOM keys (__reactFiber$, __reactContainer$, ...) — packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L43-L49 (opens in a new tab) ↩

  20. markContainerAsRoot — packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L113-L116 (opens in a new tab) ↩

  21. getClosestInstanceFromNode — packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L136 (opens in a new tab) ↩

  22. listenToAllSupportedEvents — packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L432-L459 (opens in a new tab) ↩

  23. ReactDOMRoot, render, unmount — packages/react-dom/src/client/ReactDOMRoot.js#L102-L169 (opens in a new tab) ↩