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
The call chain
flowchart TD
A["createRoot(container, options)<br/>ReactDOMRoot.js"] --> B{isValidContainer?}
B -- no --> X["throw 'Target container is not a DOM element.'"]
B -- yes --> C["read options: onUncaughtError, onCaughtError,<br/>onRecoverableError, identifierPrefix, unstable_strictMode"]
C --> D["createContainer(container, ConcurrentRoot, ...)<br/>ReactFiberReconciler.js"]
D --> E["createFiberRoot(...)<br/>ReactFiberRoot.js"]
E --> E1["new FiberRootNode(containerInfo, tag, ...)"]
E --> E2["createHostRootFiber(tag, isStrictMode)<br/>ReactFiber.js"]
E --> E3["root.current = hostRootFiber<br/>hostRootFiber.stateNode = root"]
E --> E4["memoizedState = {element: null, isDehydrated: false, cache}"]
E --> E5["initializeUpdateQueue(hostRootFiber)"]
D --> F["registerDefaultIndicator(...)"]
A --> G["markContainerAsRoot(root.current, container)"]
A --> H["listenToAllSupportedEvents(container)"]
A --> I["return new ReactDOMRoot(root)"]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 usehydrateRoot, - passing a JSX element as the options argument (
createRoot(el, <App/>), a leftover habit from the oldReactDOM.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
// 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
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
// 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
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).
The circular link, drawn
flowchart LR
subgraph DOM
C["div#root<br/>__reactContainer$xyz → HostRoot"]
end
subgraph Reconciler
FR["FiberRootNode<br/>containerInfo<br/>current<br/>pendingLanes = 0<br/>callbackNode = null<br/>next = null"]
HR["HostRoot fiber (tag 3)<br/>stateNode<br/>memoizedState = {element: null}<br/>updateQueue.shared.pending = null<br/>alternate = null<br/>child = null"]
end
RD["ReactDOMRoot<br/>_internalRoot"]
RD -->|_internalRoot| FR
FR -->|current| HR
HR -->|stateNode| FR
FR -->|containerInfo| C
C -.->|"__reactContainer$..."| HRAfter 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
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.
warnIfReactDOMContainerInDEVuses it (viaisContainerMarkedAsRoot) to detect "you already called createRoot on this container".4 - Event dispatch. When a native event fires,
getClosestInstanceFromNodewalks 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, ande.stopPropagation()works the way people expect against non-React handlers ondocument. - No per-element listeners. Adding an
onClicklater 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) setsReactDOMSharedInternals.pbased on the event type before calling into React. That is how aclickhandler'ssetStateends up onSyncLane(see Lanes & Scheduler, setState).
The full mechanics are on Event System.
Step 5: the ReactDOMRoot object
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:
renderno 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.unmountis synchronous. It usesupdateContainerSync(SyncLane) followed byflushSyncWork(), and in DEV it warns if called during a render. It sets_internalRoot = nullfirst, so a secondrenderafterunmountthrowsCannot 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 throwsTarget container is not a DOM element., typically anullfromgetElementById.ConcurrentRootonly: withdisableLegacyMode, every root is concurrent andReactDOM.renderis gone, so automatic batching is unconditional.createFiberRoot: builds the cycle,root.currentis the HostRoot andhostRootFiber.stateNodeis the root.FiberRootNode: not a fiber, a plain object for per-tree state such aspendingLanesandcallbackNode.createHostRootFiber: setsmodebits every fiber below inherits; no alternate exists untilprepareFreshStack.initializeUpdateQueue: the class-component queue shape, so rendering the root means processing it to compute a newelement.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 lateronClickprops cost nothing at the DOM level.ReactDOMRoot: one field, two methods;rendertakes no callback andunmountis synchronous.
Footnotes
-
createRoot— packages/react-dom/src/client/ReactDOMRoot.js#L171-L262 (opens in a new tab) ↩ ↩2 -
react-dom/clientexports — packages/react-dom/src/client/ReactDOMClient.js#L52 (opens in a new tab) ↩ -
isValidContainer— packages/react-dom-bindings/src/client/ReactDOMContainer.js#L19-L29 (opens in a new tab) ↩ -
warnIfReactDOMContainerInDEV— packages/react-dom/src/client/ReactDOMRoot.js#L363-L380 (opens in a new tab) ↩ ↩2 -
createContainer— packages/react-reconciler/src/ReactFiberReconciler.js#L235-L280 (opens in a new tab) ↩ ↩2 -
disableLegacyMode = true— packages/shared/ReactFeatureFlags.js#L205 (opens in a new tab) ↩ -
react-dommain exports — packages/react-dom/index.js#L10-L26 (opens in a new tab) ↩ -
React 19 Upgrade Guide, "Removed: ReactDOM.render" — https://react.dev/blog/2024/04/25/react-19-upgrade-guide (opens in a new tab) ↩
-
createHydrationContainer— packages/react-reconciler/src/ReactFiberReconciler.js#L282-L351 (opens in a new tab) ↩ -
createFiberRoot— packages/react-reconciler/src/ReactFiberRoot.js#L157-L236 (opens in a new tab) ↩ -
FiberRootNodeconstructor — packages/react-reconciler/src/ReactFiberRoot.js#L50-L155 (opens in a new tab) ↩ ↩2 -
root.current = finishedWorkin commit — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4047-L4051 (opens in a new tab) ↩ -
createHostRootFiber— packages/react-reconciler/src/ReactFiber.js#L529-L551 (opens in a new tab) ↩ -
<StrictMode>sets mode bits — packages/react-reconciler/src/ReactFiber.js#L602-L608 (opens in a new tab) ↩ -
prepareFreshStackcreates the root WIP — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2254-L2258 (opens in a new tab) ↩ -
initializeUpdateQueue— packages/react-reconciler/src/ReactFiberClassUpdateQueue.js#L176-L189 (opens in a new tab) ↩ -
updateHostRoot— packages/react-reconciler/src/ReactFiberBeginWork.js#L1831 (opens in a new tab) ↩ -
internal DOM keys (
__reactFiber$,__reactContainer$, ...) — packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L43-L49 (opens in a new tab) ↩ -
markContainerAsRoot— packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L113-L116 (opens in a new tab) ↩ -
getClosestInstanceFromNode— packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L136 (opens in a new tab) ↩ -
listenToAllSupportedEvents— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L432-L459 (opens in a new tab) ↩ -
ReactDOMRoot,render,unmount— packages/react-dom/src/client/ReactDOMRoot.js#L102-L169 (opens in a new tab) ↩