8.1 SSR and hydration basics
How can a page show up instantly — already painted, already readable — and yet still become a real interactive React app a moment later? The server sent plain HTML that React never touched in the browser. So how does React reconnect itself to markup it did not create, without throwing it all away and starting over? That reconnection is called hydration, and this lesson is how it works.
The idea: two separate renders, one for each side
When you use server-side rendering, your components run twice — once on the server, once in the browser. Each run does a different job.
- Server: React renders your components to an HTML string and sends it to the browser. The browser parses and paints it immediately — the user sees content before any JavaScript loads.
- Client: React's JavaScript bundle arrives. Instead of deleting the existing DOM and recreating it (
createRootwould do this),hydrateRootwalks the existing DOM, builds a Fiber tree, and attaches event listeners — all without touching what the browser already painted.
Server (Node.js): 1. ReactDOMServer.renderToString(<App />) 2. Emits HTML string: "<div><h1>Hello</h1><button>Click</button></div>" 3. Sends HTML to browser Browser (before React loads): 1. HTML parser builds DOM → user sees content immediately ✅ 2. Button is NOT clickable yet ❌ Browser (React loads — hydrateRoot): 1. React calls your components again to get JSX 2. Builds a NEW fiber tree via beginWork / completeWork 3. Matches each fiber to the existing DOM node (no createElement!) 4. Attaches event listeners via commit phase ✅ 5. Now interactive!
Read that last block carefully: hydration still builds a brand-new Fiber tree. What it skips is creating DOM nodes — the nodes already exist, painted by the browser. Hydration's whole job is to point each new Fiber at the DOM node that is already there, then wire up events.
Who creates what: server vs client
The two runs do not produce the same things. The biggest surprise is on the server side.
Server (renderToString) | Client (hydrateRoot) | |
|---|---|---|
| Runs components? | Yes — calls App(), Counter(), etc. | Yes — runs them again |
| Creates JSX elements? | Yes — { type: 'div', props: ... } | Yes — same elements |
| Creates fibers? | No — fibers don't exist on server | Yes — full fiber tree |
| Creates DOM nodes? | No — produces HTML string | No — reuses existing DOM |
| Output | HTML string sent to browser | Fiber tree attached to existing DOM |
The entry point: hydrateRoot
You choose hydration. React does not auto-detect server HTML — there is no SSR signature in the markup; the choice is architectural. You call hydrateRoot instead of createRoot, and that single difference changes everything downstream.
The real code
// client.js — developer's conscious choice
import { hydrateRoot } from 'react-dom/client';
import App from './App';
const container = document.getElementById('root');
hydrateRoot(container, <App />); // ← reuses existing DOM
// Compare: pure CSR
// createRoot(container).render(<App />); // ← deletes existing DOM, slower
Inside, hydrateRoot sets one flag — hydrate = true — and schedules an initial hydration instead of a normal update. That flag is the only real fork from createRoot; everything else rejoins the same render machinery you already know.
Show the full source
function hydrateRoot(container, initialChildren, options) {
if (!isValidContainer(container)) {
throw new Error('hydrateRoot(...): Target container is not a DOM element.');
}
// createHydrationContainer stores container in FiberRootNode.containerInfo
// and passes hydrate = true to createFiberRoot
var root = createHydrationContainer(
initialChildren, null, container, ConcurrentRoot,
hydrationCallbacks, isStrictMode, ...
);
markContainerAsRoot(root.current, container);
listenToAllSupportedEvents(container);
return new ReactDOMHydrationRoot(root);
}
function createHydrationContainer(initialChildren, callback, containerInfo, tag, ...) {
var hydrate = true; // ← KEY FLAG — tells createFiberRoot we are hydrating
var root = createFiberRoot(containerInfo, tag, hydrate, initialChildren, ...);
// scheduleInitialHydrationOnRoot (not scheduleUpdateOnFiber)
// — this is the only fork vs createRoot
scheduleInitialHydrationOnRoot(root, lane, eventTime);
return root;
}
The cursor: how Fibers find their DOM nodes
During hydration React needs to link each host Fiber to the DOM node the browser already created. Here is the trick: it does not search by id or by any attribute. It uses a single cursor that walks the DOM in the exact same depth-first order that React visits Fibers.
This works only because of one invariant. The order in which the server emitted HTML nodes is the same order in which the client visits host Fibers:
Server renderToString DFS order === Client fiber tree host-node DFS order
So React keeps two module-level variables: one says which Fiber's children it is currently matching, and one is the cursor — the next DOM node to claim.
The real code
var hydrationParentFiber = null; // "which fiber's DOM children am I matching?"
var nextHydratableInstance = null; // "which DOM node is next?" ← THE CURSOR
Only two kinds of Fiber ever touch this cursor: HostComponent (a real tag like <div>) and HostText (a text node). Function components, fragments, and context providers are invisible to hydration — they produce no DOM, so the cursor skips right past them.
As beginWork goes down the tree, each host Fiber claims the node under the cursor and the cursor steps into that node's children. As completeWork comes back up, the cursor advances to the next sibling. The single most important line in all of hydration is this one:
A full cursor walk, step by step
Say the browser already holds this server HTML:
<div id="root">
<div class="app">
<h1>Hello</h1>
<button>Click</button>
</div>
</div>
And your client code re-renders the same shape, this time with a real onClick handler:
function App() { // ← FunctionComponent — invisible to cursor
return (
<div className="app">
<h1>Hello</h1>
<button onClick={handler}>Click</button>
</div>
);
}
hydrateRoot(document.getElementById('root'), <App />);
Watch the cursor track the DOM as the Fiber visit order marches along:
Fiber DFS visit order: DOM cursor position:
HostRoot container.firstChild → <div.app>
└─ App (FC) ← invisible (cursor unchanged)
└─ div.app ← CLAIM <div.app> ✅ → cursor moves to div.firstChild
├─ h1 ← CLAIM <h1> ✅ → cursor moves to h1.firstChild
│ └─ "Hello" ← CLAIM "Hello" text ✅ → completeWork → cursor to h1.nextSibling
└─ button ← CLAIM <button> ✅ → completeWork → updatePayload = [onClick]
After commit phase: onClick handler attached to existing <button> DOM node ✅
Notice the App function component sits in the Fiber tree but never moves the cursor — exactly as promised. And the onClick handler is applied at the very end, during the commit phase, to the same button node the browser painted.
Down (claim) and back up (advance), in source
Going down, beginWork for a host component calls tryToClaimNextHydratableInstance, which tries to match the cursor's node to the Fiber. If it matches, tryHydrate sets fiber.stateNode to that existing node and moves the cursor into its children. If it can't match — even after a one-sibling-ahead retry — the node is treated as a new insertion.
Show the full source
Enter hydration state (initialize the cursor)
// updateHostRoot detects a hydration root via prevState.isDehydrated
function enterHydrationState(fiber) {
// fiber.stateNode = FiberRootNode
// fiber.stateNode.containerInfo = the <div id="root"> YOU passed to hydrateRoot()
var parentInstance = fiber.stateNode.containerInfo;
// getFirstHydratableChildWithinContainer calls container.firstChild,
// then getNextHydratable() to skip non-hydratable nodes (comments, whitespace)
nextHydratableInstance = getFirstHydratableChildWithinContainer(parentInstance);
hydrationParentFiber = fiber;
isHydrating = true;
return true;
}
function getNextHydratable(node) {
for (; node != null; node = node.nextSibling) {
var nodeType = node.nodeType;
if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) break;
if (nodeType === COMMENT_NODE) break; // Suspense markers (e.g. <!--$-->)
}
return node;
}
beginWork: claim the node under the cursor (going DOWN)
// beginWork → updateHostComponent — only called for HostComponent
function updateHostComponent(current, workInProgress, renderLanes) {
if (current === null) {
tryToClaimNextHydratableInstance(workInProgress); // ← ONLY host components
}
// ... reconcileChildren follows
}
function tryToClaimNextHydratableInstance(fiber) {
if (!isHydrating) return;
var nextInstance = nextHydratableInstance; // grab cursor
if (!nextInstance) {
// No DOM node left — mark as new insertion
insertNonHydratedInstance(hydrationParentFiber, fiber);
return;
}
var firstAttemptedInstance = nextInstance;
if (!tryHydrate(fiber, nextInstance)) {
// Mismatch — try next sibling as a heuristic
nextInstance = getNextHydratableSibling(firstAttemptedInstance);
if (!nextInstance || !tryHydrate(fiber, nextInstance)) {
insertNonHydratedInstance(hydrationParentFiber, fiber);
return;
}
deleteHydratableInstance(hydrationParentFiber, firstAttemptedInstance);
}
}
function tryHydrate(fiber, nextInstance) {
switch (fiber.tag) {
case HostComponent: {
var instance = canHydrateInstance(nextInstance, type);
// canHydrateInstance checks: instance.nodeName.toLowerCase() === fiber.type
if (instance !== null) {
fiber.stateNode = instance; // ← LINK fiber → DOM
hydrationParentFiber = fiber;
nextHydratableInstance = getFirstHydratableChild(instance);
// ↑ cursor moves DOWN into children
return true;
}
return false;
}
case HostText: {
var textInstance = canHydrateTextInstance(nextInstance, text);
// canHydrateTextInstance checks: nodeType === TEXT_NODE
if (textInstance !== null) {
fiber.stateNode = textInstance; // ← LINK fiber → DOM
nextHydratableInstance = null; // text has no children
return true;
}
return false;
}
}
}
Going back up, completeWork for a host component calls popHydrationState, which advances the cursor to the next sibling and deletes any leftover server nodes the client didn't produce. In normal (non-hydration) rendering this same spot would instead call createInstance() and build the DOM from scratch — same function, different branch.
Show the full source
// completeWork → HostComponent case
var wasHydrated = popHydrationState(workInProgress); // JOB 1: advance cursor
if (wasHydrated) {
// JOB 2: diff server props vs client props
if (prepareToHydrateHostInstance(workInProgress, rootContainerInstance, currentHostContext)) {
markUpdate(workInProgress); // schedule prop patch for commit phase
}
} else {
// REGULAR (non-hydration) flow: create DOM from scratch
var instance = createInstance(type, newProps, ...); // document.createElement()
appendAllChildren(instance, workInProgress, ...);
workInProgress.stateNode = instance;
}
bubbleProperties(workInProgress); // JOB 3: always runs
// ---
function popHydrationState(fiber) {
if (fiber !== hydrationParentFiber) return false; // FC fibers hit this → skip
// Delete any DOM nodes the server sent but the client didn't produce
while (nextHydratableInstance) {
deleteHydratableInstance(fiber, nextHydratableInstance);
nextHydratableInstance = getNextHydratableSibling(nextHydratableInstance);
}
popToNextHostParent(fiber); // walk up, skipping FC/Fragment/etc.
nextHydratableInstance = getNextHydratableSibling(fiber.stateNode);
// ↑ cursor → next sibling DOM (= fiber.stateNode.nextSibling)
return true;
}
Switching gears: how hooks run during server rendering
Your components run on the server too — so what happens when one of them calls useState or useEffect? There is no Fiber to store state on, no DOM to touch, and no commit phase to run effects in. React's answer is clever: it swaps out the entire hook implementation.
A dispatcher is just an object whose methods are useState, useEffect, and friends. useState(...) in your code really means "call whatever useState the current dispatcher provides." Change the pointer, change what every hook does.
ReactCurrentDispatcher (global pointer)
│
├─── CLIENT dispatcher (HooksDispatcherOnMount / OnUpdate / OnRerender)
│ useState → stores in fiber.memoizedState linked list
│ useEffect → queues effect on fiber.updateQueue
│ useRef → stores {current} in fiber.memoizedState
│ useContext→ reads value AND tracks dependency on fiber
│
└─── SERVER dispatcher (Fizz — ReactFizzServer)
useState → returns [initialState, no-op]
useEffect → does nothing at all
useRef → returns {current: initialValue}
useContext→ reads context._currentValue (no tracking)
The server dispatcher is deliberately tiny. There is no Fiber, so hook state lives in a plain array that lasts only for this one render. useState returns its initial value and a setter that does nothing — because there are no re-renders on the server. useEffect is ignored entirely.
The real code
// Conceptually (ReactFizzServer.js):
let currentHookIndex = 0;
let hookStates = []; // flat array, NOT fiber.memoizedState linked list
function useState(initialState) {
const state = typeof initialState === 'function'
? initialState() // lazy initializer still runs
: initialState;
hookStates[currentHookIndex++] = state;
return [state, () => {}]; // setState is a no-op — no re-renders on server
}
function useEffect(create, deps) {
// completely ignored — no commit phase exists on the server
}
function useRef(initialValue) {
return { current: initialValue }; // simple object, no DOM behind it
}
function useContext(Context) {
return readContext(Context); // reads Context._currentValue directly
}
This works because server rendering is one-shot: no updates, no re-renders, no diffing, no DOM — just traverse elements and emit HTML.
So the rule of thumb is simple. Render-phase hooks run on the server: useState, useReducer, useMemo, useCallback, useContext, useRef, and useId all return useful values. Commit-phase hooks do not: useEffect, useLayoutEffect, useInsertionEffect, and useImperativeHandle have no commit phase to run in, so their bodies never execute on the server.
Show the full source
Client dispatchers (one per render phase)
// Client-side dispatcher names (react-dom)
HooksDispatcherOnMountInDEV // first render
HooksDispatcherOnUpdateInDEV // subsequent renders
HooksDispatcherOnRerenderInDEV // re-render within same render pass
ContextOnlyDispatcher // outside a render (invalid hook call guard)
SSR entry points
// React 18+ streaming SSR
import { renderToPipeableStream } from 'react-dom/server';
const { pipe } = renderToPipeableStream(<App />, {
onShellReady() {
pipe(response); // streams HTML chunks as Suspense boundaries resolve
}
});
// Web Streams API variant
import { renderToReadableStream } from 'react-dom/server';
const stream = await renderToReadableStream(<App />);
Both server entry points walk the React element tree directly — no Fiber is ever created on the server. Fibers appear only on the client during hydrateRoot(), when React needs to attach event handlers and enable state updates.
SERVER (Fizz)
React elements ──► walk element tree ──► HTML string/stream
(no fibers, no commit, no effects)
│
│ HTML sent to browser
▼
CLIENT (Hydration)
HTML + JS ──► hydrateRoot() ──► create fiber tree
(fibers created here to track state and events)