createRoot and the First Render
What you'll learn
- What
createRoot(container)actually builds before anything renders - The surprise hiding in root creation: listeners for every event type, attached to your container
- Why
root.render(<App />)schedules the first render instead of doing it on the spot - The complete first-render pipeline: components called top-down, DOM built bottom-up in memory, one insertion, refs, paint, effects
root.unmount(), running multiple roots on one page, and what happened to the oldReactDOM.render
Every React app you've ever written starts with the same incantation: import createRoot, point it at a div, call .render(). Most of us copy those three lines on day one and never think about them again. But each line does something concrete, and one of them does something almost nobody expects: it attaches dozens of event listeners before a single component of yours has run. Let's open the entry point.
The three lines, honestly
import { createRoot } from 'react-dom/client';
import App from './App';
const container = document.getElementById('root');
const root = createRoot(container);
root.render(<App />);
Two separate acts live here. createRoot prepares a home for your UI. root.render asks React to fill it. Different jobs, different machinery, so we'll take them one at a time.
What createRoot builds
createRoot(container) touches no component of yours. It builds React-side infrastructure:
- An internal root object, the anchor React uses for everything that happens in this container: scheduling renders, tracking the tree, dispatching events.
- A brand-new internal tree, at first just a single root node, waiting for children. This is the top of the fiber tree from Part 2; your components will hang below it.
- Event listeners on the container, the surprise. React attaches a listener (actually a capture-phase and a bubble-phase pair) for every event type it supports, around eighty of them:
click,keydown,mouseover,input, and on and on, directly onto your container div.
Jargon: "root". The long-lived object, created once per
createRootcall, that links a DOM container to its own internal tree. Your whole app lives inside one root; the root is whatroot.renderandroot.unmounttalk to.
Listeners before any component exists? Yes. Your onClick props never become DOM listeners on your elements. Instead, React listens for everything at the container, once, up front, and when an event arrives, it works out which of your handlers to call. That system is the entire next chapter; for now, file away one fact: the listeners appear at createRoot time, on the container, whether or not you ever write a single onClick.
Pseudocode model, not real source:
// Conceptually, createRoot does something like:function createRoot(container) {const root = {container, // your divinternalTree: makeEmptyTree(), // one root node, no children yet};for (const type of ALL_EVENT_TYPES) { // ~80 typescontainer.addEventListener(type, dispatch, false); // bubblecontainer.addEventListener(type, dispatch, true); // capture}return root;}
root.render schedules: it doesn't render
Here is the second surprise: root.render(<App />) does not synchronously build your page. Like setState (Part 3), it enqueues a request: "render this element into this root when you get a chance." React schedules the work and flushes it promptly, typically before the browser paints, but the call itself is a request, not an immediate execution.
Two practical consequences:
- Don't expect to read fresh DOM on the very next line after
root.render(...). Give React its moment, an effect, a timeout, the next frame. root.rendercan be called again later with a new element, it's the top-level update. Modern apps rarely need this (state handles updates from inside), but it's how hot-reloading tools swap your whole app in place.
What happens when you call root.render(<App />):
- React stores the element as a pending update on the root's internal node.
- A render is scheduled for this root, other roots on the page are untouched.
- React picks up the work almost immediately and begins the first render.
The first render, step by step
Now the main event: everything from Parts 1–2, replayed end-to-end for a cold start.
Step 1: elements are created. <App /> is already just an object, type App, empty props. Creating it touched nothing.
Step 2: components are called, top-down, and internal nodes are built as you go. React makes an internal node for App, calls App(), and gets back more elements. For each child element: make a node, and if it's a component, call it too. Down and down the tree, until every branch bottoms out at host elements, 'div', 'span', text. A parent is always called before its children can exist.
Step 3: host DOM nodes are created, bottom-up, in memory. As each branch finishes, React creates the real DOM nodes, detached, invisible, not in the page. Children are materialized before their parents, so each parent can append its children while still off-screen. When this step completes, React is holding your entire initial page as a fully-assembled DOM subtree that the browser has never laid out or painted.
Step 4: one insertion. The completed subtree is attached to your container with a single DOM operation. One insertion, one layout, one paint, not hundreds.
Step 5: refs attach. Every ref object gets its .current pointed at the now-real DOM node.
Step 6: effects take their places. Layout effects run immediately, before paint. Your useEffects are scheduled for after paint.
Step 7: the browser paints. The user sees the first frame of your app.
Step 8: passive effects run. Subscriptions start, fetches kick off, timers begin, after pixels are on screen, so they can't delay them.
That ordering, everything ready before anything is shown, is why you never watch a React page assemble itself node by node. The first frame is already complete.
Why the in-memory build matters
Why not append each node to the page as it's created? Because every attachment to the live document invites the browser to recalculate style and layout around a half-built tree. Fifty rows appended one by one can mean fifty layouts. Building detached and inserting once means the browser lays out your initial page exactly once, and it's why the commit chapter's rule ("build off-screen, attach once") applies with full force to the very first render.
root.unmount: taking it all back
root.unmount();
One call tears the whole thing down:
- Every effect cleanup in the tree runs, subscriptions closed, timers cleared.
- Refs detach:
ref.currentgoes back tonull. - The DOM nodes are removed from the container.
- The internal tree and the root itself are discarded.
After unmount, that root is dead, don't call root.render on it again. And note what unmount is not: it's not root.render(null). Rendering null empties the screen but keeps the root (and its listeners) alive for future renders; unmount retires the root entirely.
Many roots, one page
Nothing says a page gets one root. Each createRoot call on a different container creates a fully independent world:
import { createRoot } from 'react-dom/client';
import Header from './Header';
import Chart from './Chart';
createRoot(document.getElementById('header-root')).render(<Header />);
createRoot(document.getElementById('chart-root')).render(<Chart />);
What happens:
- Each root gets its own internal tree, its own scheduled renders, and its own set of event listeners on its own container.
- State in
<Header />re-renders the header root only; the chart root never stirs. - Events don't cross. A click inside the chart container bubbles to the chart root's listeners, which map it into the chart's internal tree. The header root never hears about it, its listeners sit on a different container that the event never reaches. (The full dispatch story is next chapter.)
This is how React coexists with legacy pages and other frameworks: sprinkle roots wherever you need islands of React. (If you instead want one tree whose DOM appears in several places, that's a portal, chapter 4.)
Legacy corner: ReactDOM.render
Before React 18, the entry point was one call instead of two:
// The old way — do not use in modern code
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));
It rendered synchronously by default and had no root object to schedule on, which is precisely why the concurrent machinery of Part 4 couldn't exist under it. React 18 kept it working with a development warning (and without concurrent features); current React has removed it from the renderer entirely. If you meet it in an old codebase: ReactDOM.render(a, c) becomes createRoot(c).render(a), and ReactDOM.unmountComponentAtNode(c) becomes root.unmount().
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception:
createRootrenders your app. Reality: it only prepares infrastructure, root object, empty internal tree, container listeners. Nothing renders untilroot.render. - Misconception:
root.render(<App />)builds the page synchronously. Reality: it schedules an update. The first render flushes promptly (before paint), but the call itself is a request, likesetState. - Misconception: Event listeners appear when you write
onClick. Reality: listeners for ~80 event types are attached to the container atcreateRoottime, even if your app has zero handlers. - Misconception: The first render inserts DOM nodes as they're created. Reality: the whole subtree is assembled detached in memory and inserted with one operation, one layout, one paint.
- Misconception:
root.unmount()is the same as renderingnull. Reality: renderingnullkeeps the root alive for future renders;unmountdestroys the root itself. - Misconception: Multiple roots on a page share one React tree. Reality: each root owns a separate tree, scheduler, and listener set, fully independent islands.
Why it works this way
- A root object gives React a handle on everything. Scheduling, the internal tree, and event dispatch all need an owner that lives as long as the app does.
- Separating "prepare" from "render" keeps the API honest.
createRootis setup;renderis an update request, and all updates in React, fromsetStateon up, are requests that React schedules. - A scheduled (not synchronous) first render keeps one rule for everything. The same pipeline that batches and prioritizes later updates also carries the first one, no special-case rendering path.
- Listeners up front means delegation for free. Attaching once at the container means zero listener work per element, forever, the next chapter cashes this check.
- Detached construction buys a single layout. In-memory assembly is pure-JS fast; the browser pays for layout and paint once, at one insertion.
- Independent roots enable incremental adoption. React can take over one div of a legacy page without owning the rest, isolation is what makes that safe.
Try it yourself
- Between
createRoot(container)androot.render(<App />), logcontainer.innerHTML. Empty, proving root creation renders nothing. Then log inside auseEffectinAppto see when the DOM really arrives. - Create a root for an app with no handlers anywhere, then open DevTools → Elements, select the container div, and open the Event Listeners panel. Dozens of listeners,
click,keydown,mouseover…, all attached to the container. Now you know when they got there. - Create two roots with two independent counters (the "many roots" pattern). Confirm each updates alone, and, in the Event Listeners panel, that each container carries its own listener set.
- Add
console.logto an effect cleanup, render, then callroot.unmount()from the console. The cleanup logs and the container empties.
Recap
createRoot(container)builds a root object, an empty internal tree, and ~80 event-type listeners on the container, before any component runs.root.render(<App />)schedules the first render; it doesn't perform it inline.- First render: components called top-down → internal nodes built → DOM created bottom-up, detached → one insertion → refs attach → effects scheduled → paint → effects run.
- Building in memory, then inserting once, means one layout and one paint for the entire initial page.
root.unmount()runs cleanups, detaches refs, removes DOM, and retires the root; renderingnullis the gentler option.- Multiple roots are independent islands, separate trees, separate listeners, no event crossing.
- The pre-18
ReactDOM.renderwas synchronous-by-default and has been removed from the modern renderer.