Hydration inside
Watch React 19.3.1 hydrate a page node by node: the hydration cursor, claiming DOM nodes, mismatches, Suspense comment markers, and selective hydration.
- 01Follow the hydration cursorExplain how fibers claim the server's nodes in beginWork, and how completeWork links them and moves the cursor on.
- 02Explain what a mismatch costsSay what React checks while hydrating, what throwOnHydrationMismatch does, and why the nearest Suspense boundary decides how much is rendered again.
- 03Read Suspense markers and selective hydrationRead the comment markers in server HTML, and explain how dehydrated boundaries hydrate later, or at once when you click inside one.
React walks the server's HTML
Server rendering and hydration showed hydration from the outside: the server sends HTML, and hydrateRoot makes the same elements work, as long as both sides render the same thing. This lesson opens up React 19.3.1 to see how. The short answer: hydration is an ordinary render in which React reads the page instead of writing to it. Here is a product page the server rendered, hydrated with a spy that logs every step. Read the log under the result:
import { hydrateRoot } from "react-dom/client";import ProductPage from "./ProductPage.jsx";import { watchCursor, watchLinks } from "./CursorSpy.js"; const root = document.getElementById("root");const nodes = watchCursor(root);hydrateRoot(root, <ProductPage />);watchLinks(root, nodes);The index.html tab holds the HTML renderToString made for ProductPage, in the ProductPage.jsx tab. Line 6 calls watchCursor from the CursorSpy.js tab before React runs. It replaces firstChild and nextSibling on every node the server sent, so each time any code reads one, a line appears: <main>.firstChild → <h1> means "something asked <main> for its first child and got <h1>". Line 7 hydrates. Line 8 adds a second kind of line, linked <h1> to the h1 fiber, for the moment React stores a fiber on a node. (The spy reads React's private fields, for looking only.)
The 18 lines say three things:
- React never creates an element. It reads the server's nodes one by one, with
firstChildandnextSibling, in the same order it visits the fibers. That position is the hydration cursor. - Every node gets linked to a fiber, so the server's
<button>becomes the button React updates. - The comment
<!-- -->is read past and never linked. It only marks where"In cart: "ends and"0"begins.
Click Add to cart: the count changes, and the log doesn't grow. The walk happened once, during the first render.
<main> and <h1>: each fiber's stateNode is that node. The cursor, nextHydratableInstance, points at <p>, so the p fiber claims it next. No node is created.A new manager takes over a shop that is already stocked. They don't empty the shelves and fill them again. They walk along with a list, one item at a time, check that each item is what the list says, and put a price sticker on it. If a shelf doesn't match the list, they clear that one shelf and restock it.
- In real life: The shelves, already stocked, when the new manager arrives
- In React: The HTML the server sent
- In real life: The manager's list of what should be on each shelf
- In React: The fibers your components render
- In real life: A finger moving along the shelf, item by item
- In React: The hydration cursor, nextHydratableInstance
- In real life: A price sticker on each item that matches the list
- In React: A fiber linked to each node, with its props
- In real life: Clearing a shelf whose items don't match, and restocking it
- In React: A mismatch: React removes that part and renders it in the browser
Hydration is a render that claims the server's nodes instead of creating new ones. A cursor, nextHydratableInstance, walks the HTML in the same order React walks the fibers. On the way down, each fiber claims the node at the cursor if its tag matches; on the way up, React links the fiber to the node and moves the cursor on. If a node doesn't fit, React throws that part of the HTML away and renders it in the browser, up to the nearest Suspense boundary.
hydrateRoot starts a render in hydration mode
hydrateRoot, at line 276 of ReactDOMRoot.js, looks like createRoot: it makes a root and listens for events on the container. The difference is in createHydrationContainer, which schedules the first render. From ReactFiberReconciler.js, lines 340 to 348:
const current = root.current;
let lane = requestUpdateLane(current);
lane = getBumpedLaneForHydrationByLane(lane);
const update = createUpdate(lane);
update.callback =
callback !== undefined && callback !== null ? callback : null;
enqueueUpdate(current, update, lane);
startUpdateTimerByLane(lane, 'hydrateRoot()', null);
scheduleInitialHydrationOnRoot(root, lane);Line 2 asks for a lane, the priority of an update (see Lanes). The spy example calls hydrateRoot at the top of main.jsx, outside any event, so the lane is DefaultLane, 32. Line 3 swaps it for its hydration twin, DefaultHydrationLane, 16, in getBumpedLaneForHydrationByLane. Lines 4 to 9 queue an update with no payload, only to get the root rendered. Your components aren't in it: the root already holds <ProductPage />.
When the render reaches the root fiber, updateHostRoot sees that the root is still dehydrated, meaning it holds server HTML that no fiber has claimed yet. Shortened from ReactFiberBeginWork.js, lines 1914 to 1937:
} else {
// The outermost shell has not hydrated yet. Start hydrating.
enterHydrationState(workInProgress);
const child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
workInProgress.child = child;
let node = child;
while (node) {
// …
node.flags = (node.flags & ~Placement) | Hydrating | PlacementDEV;
node = node.sibling;
}Line 3 switches hydration on. Lines 5 to 10 make the root's child fibers with mountChildFibers, as a first render does. Lines 13 to 18 then set the Hydrating flag on them and clear Placement: these fibers are new, but their nodes are already in the page, so the commit must not insert them. (The commit phase lesson shows what Placement does.)
The hydration state lives in a few variables at the top of ReactFiberHydrationContext.js:
// The deepest Fiber on the stack involved in a hydration context.
// This may have been an insertion or a hydration.
let hydrationParentFiber: null | Fiber = null;
let nextHydratableInstance: null | HydratableInstance = null;
let isHydrating: boolean = false;hydrationParentFiber(line 3): the deepest fiber whose node React is inside right now.nextHydratableInstance(line 4): the cursor, the server's node that the next fiber should claim.isHydrating(line 5): whether this part of the render claims nodes or creates them.
They are plain module variables, not fields on a fiber: one render runs at a time, so one cursor is enough. The text after : on each line is a type for Flow, the type checker React is written with. Here is enterHydrationState, at lines 162 to 179:
function enterHydrationState(fiber: Fiber): boolean {
// $FlowFixMe[constant-condition]
if (!supportsHydration) {
return false;
}
const parentInstance: Container = fiber.stateNode.containerInfo;
nextHydratableInstance =
getFirstHydratableChildWithinContainer(parentInstance);
hydrationParentFiber = fiber;
isHydrating = true;
hydrationErrors = null;
didSuspendOrErrorDEV = false;
hydrationDiffRootDEV = null;
rootOrSingletonContext = true;
return true;
}- Lines 3 to 5: a renderer that can't hydrate, like React Native's, stops here. React DOM can. (Comments starting with
$FlowFixMetell Flow to skip a check; you can read past them.) - Lines 7 to 9 put the cursor on the first node inside the container, the
<div id="root">. That is the spy's first line:<div id="root">.firstChild → <main>. - Lines 10 and 11 make the root the hydration parent and switch hydration on.
- Line 15: directly inside the root container, the cursor may skip elements it doesn't expect. The next section shows why.
Claiming the node at the cursor
The render goes down the tree as usual (the work loop lesson walks it). When beginWork reaches a fiber for an element like <h1>, updateHostComponent calls tryToClaimNextHydratableInstance first, at lines 461 to 484:
function tryToClaimNextHydratableInstance(fiber: Fiber): void {
if (!isHydrating) {
return;
}
// Validate that this is ok to render here before any mismatches.
const currentHostContext = getHostContext();
const shouldKeepWarning = validateHydratableInstance(
fiber.type,
fiber.pendingProps,
currentHostContext,
);
const nextInstance = nextHydratableInstance;
if (
!nextInstance ||
!tryHydrateInstance(fiber, nextInstance, currentHostContext)
) {
if (shouldKeepWarning) {
warnNonHydratedInstance(fiber, nextInstance);
}
throwOnHydrationMismatch(fiber);
}
}- Lines 2 to 4: outside hydration, there is nothing to claim. The fiber will create its node, as on any first render.
- Lines 8 to 12 check that the element is allowed where it is, like a
<div>inside a<p>. The last section shows why that matters. - Line 14 reads the cursor. Lines 15 to 18: if there is no node, or
tryHydrateInstancecan't use it, line 22 throws a mismatch.
tryHydrateInstance, shortened from lines 251 to 291 (the cut lines collect differences for development warnings):
function tryHydrateInstance(
fiber: Fiber,
nextInstance: any,
hostContext: HostContext,
) {
// fiber is a HostComponent Fiber
const instance = canHydrateInstance(
nextInstance,
fiber.type,
fiber.pendingProps,
rootOrSingletonContext,
);
// $FlowFixMe[invalid-compare]
// $FlowFixMe[invalid-compare]
if (instance !== null) {
fiber.stateNode = instance as Instance;
// …
hydrationParentFiber = fiber;
nextHydratableInstance = getFirstHydratableChild(instance);
rootOrSingletonContext = false;
return true;
}
return false;
}Lines 7 to 12 ask the DOM half of React whether the node at the cursor fits this fiber. If it does, line 16 is the claim: fiber.stateNode, the field that holds a fiber's DOM node (see Fiber), becomes the server's node. Line 19 makes this fiber the hydration parent, and line 20 moves the cursor inside the node, to its first child: <main>.firstChild → <h1> in the log.
The question "does it fit?" is answered by canHydrateInstance, in the host config: the file where React DOM tells the reconciler how to work with real DOM nodes (the tour of the repository explains host configs). This is a simplified version of ReactFiberConfigDOM.js, lines 4082 to 4229, without types, hidden form inputs, and the checks for elements that scripts and extensions add to the root, <head> and <body>. Lines 10 to 12 join two branches that each return the element; every other line is React's.
export function canHydrateInstance(instance, type, props, inRootOrSingleton) {
while (instance.nodeType === ELEMENT_NODE) {
const element = instance;
if (element.nodeName.toLowerCase() !== type.toLowerCase()) {
if (!inRootOrSingleton) {
// Usually we error for mismatched tags.
return null;
}
// In root or singleton parents we skip past mismatched instances.
} else {
return element;
}
const nextInstance = getNextHydratableSibling(element);
if (nextInstance === null) {
break;
}
instance = nextInstance;
}
return null;
}- Line 2: only an element can be claimed by an element's fiber. A text node or a marker comment ends the loop on line 19.
- Line 4 compares the tag:
nodeNameagainst the fiber's type,"h1". Nothing else. Text and attributes are checked later, or only for warnings. - Line 7: a different tag means no claim. That's a mismatch.
- Lines 5 and 9 are the exception. Directly in the root container (
inRootOrSingleton), React skips elements it doesn't expect and looks at the next one (lines 13 to 17), because scripts and extensions often add their own elements there. See the comment on line 4104.
The cursor never stops on a comment unless it marks something. getNextHydratable, used for every step of the cursor, is at lines 4435 to 4461:
function getNextHydratable(node: ?Node) {
// Skip non-hydratable nodes.
for (; node != null; node = (node as any as Node).nextSibling) {
const nodeType = node.nodeType;
if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) {
break;
}
if (nodeType === COMMENT_NODE) {
const data = (node as any).data;
if (
data === SUSPENSE_START_DATA ||
data === SUSPENSE_FALLBACK_START_DATA ||
data === SUSPENSE_PENDING_START_DATA ||
data === SUSPENSE_QUEUED_START_DATA ||
data === ACTIVITY_START_DATA ||
data === FORM_STATE_IS_MATCHING ||
data === FORM_STATE_IS_NOT_MATCHING
) {
break;
}
if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) {
return null;
}
}
}
return node as any;
}- Lines 5 to 7: an element or a text node is a place to stop.
- Lines 10 to 20: so is a comment that starts a Suspense or Activity boundary, or marks a form's state. The next sections meet these.
- Lines 21 to 23: an end marker means there's nothing more at this level, so the cursor becomes
null. - Any other comment is skipped: line 3 moves on with
nextSibling. That's the log's<!-- -->.nextSibling → "0".
Claiming means the elements on the page after hydration are the ones the server sent. You can check it. This example gives every element a mark before hydrating, a plain JavaScript property, so the HTML doesn't change. Click Add to cart twice, then Check the elements:
import { hydrateRoot } from "react-dom/client";import ProductPage from "./ProductPage.jsx"; const root = document.getElementById("root");root.querySelectorAll("*").forEach((element, index) => { element.serverMark = index + 1;}); hydrateRoot(root, <ProductPage />); document.getElementById("check").onclick = () => { const main = root.querySelector("main"); const key = Object.keys(main).find((name) => name.startsWith("__reactFiber$")); for (const element of root.querySelectorAll("*")) { const fiber = element[key]; console.log(`<${element.localName}> mark ${element.serverMark}, fiber.stateNode is it: ${fiber.stateNode === element}`); }};Lines 5 to 7 number the four elements the server sent. Line 9 hydrates. Lines 12 and 13 find the name of React's private key from <main>, and line 16 prints, for every element, its mark and whether its fiber's stateNode is that element. The console shows marks 1 to 4 and true four times: the same elements, each linked both ways. Now press Edit & run, change hydrateRoot to createRoot on line 1, and line 9 to createRoot(root).render(<ProductPage />);. The marks print as undefined: createRoot clears the container and makes new elements.
Linking the node and moving on
After a fiber's children are done, completeWork runs for it, on the way back up. For a hydrating element it calls two functions, in this order. First popHydrationState, shortened from lines 759 to 834:
function popHydrationState(fiber: Fiber): boolean {
// …
if (fiber !== hydrationParentFiber) {
// …
return false;
}
if (!isHydrating) {
// …
}
const tag = fiber.tag;
// …
popToNextHostParent(fiber);
if (tag === SuspenseComponent) {
nextHydratableInstance = skipPastDehydratedSuspenseInstance(fiber);
// …
} else {
nextHydratableInstance = hydrationParentFiber
? getNextHydratableSibling(fiber.stateNode)
: null;
}
return true;
}- Lines 3 to 6: a fiber that isn't the current hydration parent wasn't hydrated, so there is nothing to do.
- Line 13 cuts a check for nodes left over inside this element: if the cursor isn't
nullhere, the server sent more children than the fiber has, and that throws a mismatch too. - Line 14 makes the parent element the hydration parent again. Lines 19 to 21 move the cursor to the node after this one:
<h1>.nextSibling → <p>in the log. Lines 15 and 16 do it differently for a Suspense boundary, as you'll see.
Then prepareToHydrateHostInstance calls hydrateInstance in the host config, at lines 4579 to 4592:
export function hydrateInstance(
instance: Instance,
type: string,
props: Props,
hostContext: HostContext,
internalInstanceHandle: Object,
): boolean {
precacheFiberNode(internalInstanceHandle, instance);
// TODO: Possibly defer this until the commit phase where all the events
// get attached.
updateFiberProps(instance, props);
return hydrateProperties(instance, type, props, hostContext);
}- Line 8,
precacheFiberNode, stores the fiber on the node under the key__reactFiber$plus a random string. That's the moment the spy logslinked. When you click, React starts from the clicked node and uses this key to find its fiber. - Line 11,
updateFiberProps, stores the props, with theonClick, under__reactProps$. From now on, React's listener at the root can call your handler: the button works. - Line 13,
hydrateProperties, attaches the few listeners some elements need (a video's, a dialog's) and, for an element whose only child is text, compares that text. It returnsfalseif the text differs.
<h1>.nextSibling before linked <h1>.A text fiber, like the one for {count}, does the same with hydrateTextInstance, which calls hydrateText in ReactDOMComponent.js, lines 3384 to 3398:
export function hydrateText(
textNode: Text,
text: string,
parentProps: null | Object,
): boolean {
const isDifferent = textNode.nodeValue !== text;
if (
isDifferent &&
(parentProps === null || parentProps.suppressHydrationWarning !== true) &&
!checkForUnmatchedText(textNode.nodeValue, text)
) {
return false;
}
return true;
}Line 6 compares the text on the page with the fiber's text. Line 12 returns false if they differ, unless the parent has suppressHydrationWarning (line 9), or the two differ only in how line breaks are written (line 10). Notice what it doesn't do: it never changes the text. With suppressHydrationWarning, the server's text simply stays. How to read the table: each row is something React compares while hydrating, when it compares it, and what happens if it differs.
| What | When React checks it | If it differs |
|---|---|---|
| The kind of node and its tag | When the fiber claims the node, in beginWork: canHydrateInstance compares nodeName with the fiber's type. | A mismatch. React throws and renders the nearest Suspense boundary again in the browser, or the whole root. |
| The text of a text node | When the text fiber completes: hydrateText compares nodeValue with the text. | A mismatch, reported as "server rendered text didn't match". |
The text of an element whose only child is text, like <h1>Green tea</h1> | When the element completes: hydrateProperties compares its textContent. | A mismatch, also reported as text. |
| Nodes left over inside an element | When the element completes: popHydrationState finds the cursor isn't null. | A mismatch. The root, <html>, <head>, <body>, <form> and <button> are left unchecked. |
Attributes, like class | Only in a development build, in diffHydratedPropsForDevWarnings. | A console warning. The server's attribute stays on the page. |
Step through a recorded walk
Now read the whole log line by line. Each step is one line the spy recorded, the React function that caused it, and what that function was doing. Start with A page that matches, the product page from the top. Then try A Suspense boundary and Text that differs, which the next sections explain.
0 linked<div id="root"><main>this step<h1>"Green tea"<p>"In cart: "<!-- -->"0"<button>"Add to cart"
line 1<div id="root">.firstChild → <main>
The render starts at the root fiber. updateHostRoot calls enterHydrationState, which points the cursor, nextHydratableInstance, at the root element's first child: <main>.
Watch the left pane. In the first page, every node turns to linked except the <!-- --> comment and "Green tea", the h1's only text, which hydrateProperties checks as part of <h1>. Nothing is ever created.
When the node at the cursor doesn't fit
Every check that fails ends in throwOnHydrationMismatch. Shortened from lines 383 to 416 (the cuts build the development diff and list the usual causes):
export const HydrationMismatchException: mixed = new Error(
'Hydration Mismatch Exception: This is not a real error, and should not leak into ' +
"userspace. If you're seeing this, it's likely a bug in React.",
);
function throwOnHydrationMismatch(fiber: Fiber, fromText: boolean = false) {
// …
const error = new Error(
`Hydration failed because the server rendered ${fromText ? 'text' : 'HTML'} didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
` +
// …
'https://react.dev/link/hydration-mismatch' +
diff,
);
queueHydrationError(createCapturedValueAtFiber(error, fiber));
throw HydrationMismatchException;
}- Line 9 is the message you get, with one word chosen on line 6's
fromText:textwhen a text check failed,HTMLwhen nothing could be claimed. - Line 15 queues that error. It isn't thrown at you: React reports it later, through
onRecoverableError, because it can recover. - Line 16 throws something else:
HydrationMismatchException, lines 1 to 4, an error that only means "stop and go back". Its message says it should never reach your code.
Throwing during a render sends React to throwException, the same path a component's error or a suspended promise takes. Shortened from ReactFiberThrow.js, lines 551 to 596:
// This is a regular error, not a Suspense wakeable.
if (
getIsHydrating() &&
(disableLegacyMode || sourceFiber.mode & ConcurrentMode)
) {
markDidThrowWhileHydratingDEV();
const hydrationBoundary = getSuspenseHandler();
// If the error was thrown during hydration, we may be able to recover by
// discarding the dehydrated content and switching to a client render.
// Instead of surfacing the error, find the nearest Suspense boundary
// and render it again without hydration.
if (hydrationBoundary !== null) {
// …
if ((hydrationBoundary.flags & ShouldCapture) === NoFlags) {
// Set a flag to indicate that we should try rendering the normal
// children again, not the fallback.
hydrationBoundary.flags |= ForceClientRender;
}
markSuspenseBoundaryShouldCapture(
hydrationBoundary,
returnFiber,
sourceFiber,
root,
rootRenderLanes,
);
// …
return false;
} else {- Line 7 finds the nearest Suspense boundary above the failing fiber. Lines 8 to 11, React's own comment, say the plan.
- Line 17 sets
ForceClientRenderon that boundary, and line 19 makes it catch the throw, like an error boundary would. - Line 29 starts the case with no boundary. Then the whole root fails: when the render ends, recoverFromConcurrentError sets
ForceClientRenderon the root and renders it again, without hydrating.
The boundary renders again, and updateDehydratedSuspenseComponent sees the flag and calls retrySuspenseComponentWithoutHydrating, at lines 2880 to 2906:
function retrySuspenseComponentWithoutHydrating(
current: Fiber,
workInProgress: Fiber,
renderLanes: Lanes,
) {
// Falling back to client rendering. Because this has performance
// implications, it's considered a recoverable error, even though the user
// likely won't observe anything wrong with the UI.
// This will add the old fiber to the deletion list
reconcileChildFibers(workInProgress, current.child, null, renderLanes);
// We're now not suspended nor dehydrated.
const nextProps = workInProgress.pendingProps;
const primaryChildren = nextProps.children;
const primaryChildFragment = mountSuspensePrimaryChildren(
workInProgress,
primaryChildren,
renderLanes,
);
// Needs a placement effect because the parent (the Suspense boundary) already
// mounted but this is a new fiber.
primaryChildFragment.flags |= Placement;
workInProgress.memoizedState = null;
return primaryChildFragment;
}Line 11 marks the old content, the server's nodes, for deletion. Lines 16 to 20 mount the boundary's children as new fibers, and line 23 gives them Placement: this time the commit creates and inserts their nodes. Line 24 says the boundary is no longer dehydrated. Watch it happen. The server rendered this page when Asha's review still said 4 stars; the browser renders 5:
import { hydrateRoot } from "react-dom/client";import Page from "./Page.jsx";import { watchChanges, watchCursor, watchLinks } from "./CursorSpy.js"; const root = document.getElementById("root");const nodes = watchCursor(root);watchChanges(root);hydrateRoot(root, <Page />, { onRecoverableError(error) { console.error(error.message.split("\n")[0]); },});watchLinks(root, nodes);Line 7 adds watchChanges from CursorSpy.js, which logs nodes React removes or adds. Lines 9 to 11 print the first line of each recoverable error. The log begins like the boundary walk you'll read in the next sections. Then the <p> is claimed and linked, and its text, 4 stars, isn't the fiber's 5 stars. The console says Hydration failed because the server rendered text didn't match the client. The last six lines are the commit: clearSuspenseBoundary reads from <!--$--> to <!--/$-->, the three nodes are removed, and a new <p> is added. The <h1> and the <button> outside the boundary stay. In the playground above, Text that differs steps through the same log.
Suspense boundaries are comments in the HTML
Hydration only has the HTML to go on, so the server marks where each Suspense boundary starts and ends, with comments. The DOM half of React names them in ReactFiberConfigDOM.js, lines 279 to 285:
const ACTIVITY_START_DATA = '&';
const ACTIVITY_END_DATA = '/&';
const SUSPENSE_START_DATA = '$';
const SUSPENSE_END_DATA = '/$';
const SUSPENSE_PENDING_START_DATA = '$?';
const SUSPENSE_QUEUED_START_DATA = '$~';
const SUSPENSE_FALLBACK_START_DATA = '$!';Each value is the text inside a comment: '$' is <!--$-->. The server writes them in ReactFizzConfigDOM.js, the configuration of Fizz, React's server renderer:
// Suspense boundaries are encoded as comments.
const startCompletedSuspenseBoundary = stringToPrecomputedChunk('<!--$-->');
const startPendingSuspenseBoundary1 = stringToPrecomputedChunk(
'<!--$?--><template id="',
);
const startPendingSuspenseBoundary2 = stringToPrecomputedChunk('"></template>');
const startClientRenderedSuspenseBoundary =
stringToPrecomputedChunk('<!--$!-->');
const endSuspenseBoundary = stringToPrecomputedChunk('<!--/$-->');Line 2 starts a finished boundary, lines 3 to 6 a boundary still waiting for its content (with a <template> holding its id, so the streamed content can find it), lines 7 and 8 a boundary the server gave up on, and line 9 ends any of them. react-dom/server runs in the browser too, so this example renders five small trees and shows the HTML:
import { Activity, Suspense, use } from "react";import { renderToReadableStream, renderToString } from "react-dom/server"; function show(label, html) { const line = document.createElement("p"); line.textContent = `${label}: ${html}`; document.getElementById("root").append(line);} const never = new Promise(() => {});function Waits() { return use(never);} show("Two pieces of text", renderToString(<p>In cart: {3}</p>));show("A finished boundary", renderToString(<Suspense fallback="..."><p>5 stars</p></Suspense>));const gaveUp = renderToString(<Suspense fallback={<p>Loading...</p>}><Waits /></Suspense>);show("A boundary the server gave up on", gaveUp.replace(/ data-[a-z]+="[^"]*"/g, ""));show("An Activity boundary", renderToString(<Activity><p>Filters</p></Activity>)); const stream = await renderToReadableStream(<main><Suspense fallback={<p>Loading...</p>}><Waits /></Suspense></main>);const { value } = await stream.getReader().read();show("A boundary still on its way", new TextDecoder().decode(value));- Line 15: two pieces of text side by side get
<!-- -->between them. A browser would readIn cart: 3as one text node; the comment keeps them two, one for each text fiber. - Line 16: a boundary whose content rendered is wrapped in
<!--$-->and<!--/$-->. - Lines 17 and 18:
Waits(lines 10 to 13) suspends forever, andrenderToStringcan't wait, so the server sends the fallback in<!--$!-->. Line 18 removes the long development-only message from the<template>so the line fits. - Line 19: a visible
<Activity>gets<!--&-->and<!--/&-->. - Lines 21 to 23 stream the same waiting boundary and print the first chunk:
<!--$?-->, the template with idB:0, and the fallback. A small script follows; the streaming lesson covers those.
How to read the table: each row is a marker, who writes it, and what hydration does when the cursor reaches it.
| Marker | Who writes it | What hydration does |
|---|---|---|
<!-- --> | The server, between two pieces of text, like In cart: and 3. | getNextHydratable skips it. Each piece becomes its own text node. |
<!--$--> … <!--/$--> | The server, around a Suspense boundary it finished. | The first pass claims the start comment and steps over the content. A later render goes inside. |
<!--$?--> … <!--/$--> | A stream, around a boundary whose content hasn't arrived. | Pending: React leaves the fallback in place until the content streams in. If the page has finished loading and it's still $?, React renders it in the browser. |
<!--$~--> | React's inline script, when the content arrived and waits to be swapped in. | Also pending. |
<!--$!--> … <!--/$--> | The server, around a boundary it gave up on. | React renders that boundary in the browser, without hydrating it. |
<!--&--> … <!--/&--> | The server, around a visible <Activity>. | Hydrated later, like a Suspense boundary. |
<!--$~--> isn't in the server's output at all: React's inline script in the page sets it, when streamed content has arrived and is about to be swapped in.
Dehydrated boundaries hydrate later
When the cursor reaches a boundary's start comment, the Suspense fiber claims the comment itself with tryHydrateSuspense, at lines 348 to 381:
function tryHydrateSuspense(
fiber: Fiber,
nextInstance: any,
): null | SuspenseInstance {
// fiber is a SuspenseComponent Fiber
// $FlowFixMe[invalid-compare]
const suspenseInstance = canHydrateSuspenseInstance(
nextInstance,
rootOrSingletonContext,
);
// $FlowFixMe[invalid-compare]
if (suspenseInstance !== null) {
const suspenseState: SuspenseState = {
dehydrated: suspenseInstance,
treeContext: getSuspendedTreeContext(),
retryLane: OffscreenLane,
hydrationErrors: null,
};
fiber.memoizedState = suspenseState;
// Store the dehydrated fragment as a child fiber.
// This simplifies the code for getHostSibling and deleting nodes,
// since it doesn't have to consider all Suspense boundaries and
// check if they're dehydrated ones or not.
const dehydratedFragment =
createFiberFromDehydratedFragment(suspenseInstance);
dehydratedFragment.return = fiber;
fiber.child = dehydratedFragment;
hydrationParentFiber = fiber;
// While a Suspense Instance does have children, we won't step into
// it during the first pass. Instead, we'll reenter it later.
nextHydratableInstance = null;
}
return suspenseInstance;
}- Lines 7 to 10 check that the cursor is at a boundary comment.
- Lines 13 to 19 store it in the fiber's
memoizedState, underdehydrated. A Suspense fiber withmemoizedState.dehydratedset is a dehydrated boundary: its content is on the page, but no fiber has claimed it. - Lines 24 to 27 give the fiber one child, a placeholder fiber for the server's content, so deleting the boundary later deletes those nodes.
- Line 31 sets the cursor to
null: React doesn't go inside on this pass.
Then mountDehydratedSuspenseComponent decides when to come back, at lines 2949 to 2970:
function mountDehydratedSuspenseComponent(
workInProgress: Fiber,
suspenseInstance: SuspenseInstance,
renderLanes: Lanes,
): null | Fiber {
// During the first pass, we'll bail out and not drill into the children.
// Instead, we'll leave the content in place and try to hydrate it later.
if (isSuspenseInstanceFallback(suspenseInstance)) {
// This is a client-only boundary. Since we won't get any content from the server
// for this, we need to schedule that at a higher priority based on when it would
// have timed out. In theory we could render it in this pass but it would have the
// wrong priority associated with it and will prevent hydration of parent path.
// Instead, we'll leave work left on it to render it in a separate commit.
// Schedule a normal pri update to render this content.
workInProgress.lanes = laneToLanes(DefaultLane);
} else {
// We'll continue hydrating the rest at offscreen priority since we'll already
// be showing the right content coming from the server, it is no rush.
workInProgress.lanes = laneToLanes(OffscreenLane);
}
return null;
}Line 19, for a normal boundary: leave work at OffscreenLane, the lowest priority. The page already shows the server's content, so there's no hurry. Line 15, for <!--$!-->: the content must be made in the browser, so it gets DefaultLane. Line 21 returns null: no children to walk. When the fiber completes, popHydrationState calls skipPastDehydratedSuspenseInstance, which walks past the boundary's content to the node after <!--/$-->. Here is that in a real page:
import { hydrateRoot } from "react-dom/client";import Page from "./Page.jsx";import { watchCursor, watchLinks } from "./CursorSpy.js"; const root = document.getElementById("root");const nodes = watchCursor(root);hydrateRoot(root, <Page />);watchLinks(root, nodes);Page.jsx wraps Reviews in a Suspense boundary on lines 8 to 10. Read the log in two parts:
- Lines 1 to 14 of the log are the first pass. After
<h1>comes<!--$-->; the next three reads step over<p>to<button>without claiming it, and<!--$-->is linked to the Suspense fiber. This pass commits: the header and the button work now. - Lines 15 to 21 are a second render, at
OffscreenLane. It starts at<!--$-->.nextSibling, inside the boundary, and claims<p>.Reviewsruns in the browser for the first time here.
<!--$-->, keeps it as memoizedState.dehydrated, and the cursor jumps past <!--/$--> to <button>. A later render at OffscreenLane goes back and starts inside, at <p>.The second render goes through updateDehydratedSuspenseComponent, which picks one of three paths. Shortened from lines 2972 to 3162:
function updateDehydratedSuspenseComponent(
current: Fiber,
workInProgress: Fiber,
didSuspend: boolean,
didPrimaryChildrenDefer: boolean,
nextProps: SuspenseProps,
suspenseInstance: SuspenseInstance,
suspenseState: SuspenseState,
renderLanes: Lanes,
): null | Fiber {
if (!didSuspend) {
// This is the first render pass. Attempt to hydrate.
// …
if (isSuspenseInstanceFallback(suspenseInstance)) {
// This boundary is in a permanent fallback state. In this case, we'll never
// get an update and we'll never be able to hydrate the final content. Let's just try the
// client side render instead.
// …
return retrySuspenseComponentWithoutHydrating(
current,
workInProgress,
renderLanes,
);
}
// …
if (didReceiveUpdate || hasContextChanged) {
// …
} else if (isSuspenseInstancePending(suspenseInstance)) {
// …
// Mark it as having captured (i.e. suspended).
// Also Mark it as requiring retry.
workInProgress.flags |= DidCapture | Callback;
// Leave the child in place. I.e. the dehydrated fragment.
workInProgress.child = current.child;
return null;
} else {
// This is the first attempt.
reenterHydrationStateFromDehydratedSuspenseInstance(
workInProgress,
suspenseInstance,
suspenseState.treeContext,
);
const primaryChildren = nextProps.children;
const primaryChildFragment = mountSuspensePrimaryChildren(
workInProgress,
primaryChildren,
renderLanes,
);
// …
primaryChildFragment.flags |= Hydrating | PlacementDEV;
return primaryChildFragment;
}- Lines 14 to 24:
<!--$!-->. The server gave up, so render it in the browser, without hydrating. - Line 26 handles a boundary whose props or context changed before it could hydrate; the cut lines try a higher-priority lane first.
- Lines 28 to 35:
<!--$?-->or<!--$~-->. The content is still streaming in. Leave it as it is (line 34) and try again when it arrives. - Lines 36 to 51: a plain
<!--$-->. Line 38 puts the cursor inside, after the start comment, and lines 43 to 48 render the real children against it.
The two checks are in the host config, at lines 4319 to 4334:
export function isSuspenseInstancePending(instance: SuspenseInstance): boolean {
return (
instance.data === SUSPENSE_PENDING_START_DATA ||
instance.data === SUSPENSE_QUEUED_START_DATA
);
}
export function isSuspenseInstanceFallback(
instance: SuspenseInstance,
): boolean {
return (
instance.data === SUSPENSE_FALLBACK_START_DATA ||
(instance.data === SUSPENSE_PENDING_START_DATA &&
instance.ownerDocument.readyState !== DOCUMENT_READY_STATE_LOADING)
);
}Lines 13 and 14 add a case: a $? boundary on a page that has finished loading will never get its content, so it counts as a fallback too. When the second pass succeeds, completeWork clears memoizedState (ReactFiberCompleteWork.js, line 1041): the boundary is hydrated. The comments stay in the page, as the result shows.
Hydrating the part you click first
Dehydrated boundaries are hydrated in a low-priority render. What if you click inside one before that render reaches it? Every event goes through dispatchEvent in ReactDOMEventListener.js, lines 157 to 224 (the event system lesson covers the rest of it):
export function dispatchEvent(
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
targetContainer: EventTarget,
nativeEvent: AnyNativeEvent,
): void {
if (!_enabled) {
return;
}
let blockedOn = findInstanceBlockingEvent(nativeEvent);
if (blockedOn === null) {
dispatchEventForPluginEventSystem(
domEventName,
eventSystemFlags,
nativeEvent,
return_targetInst,
targetContainer,
);
clearIfContinuousEvent(domEventName, nativeEvent);
return;
}
if (
queueIfContinuousEvent(
blockedOn,
domEventName,
eventSystemFlags,
targetContainer,
nativeEvent,
)
) {
nativeEvent.stopPropagation();
return;
}
// We need to clear only if we didn't queue because
// queueing is accumulative.
clearIfContinuousEvent(domEventName, nativeEvent);
if (
eventSystemFlags & IS_CAPTURE_PHASE &&
isDiscreteEventThatRequiresHydration(domEventName)
) {
while (blockedOn !== null) {
const fiber = getInstanceFromNode(blockedOn);
if (fiber !== null) {
attemptSynchronousHydration(fiber);
}
const nextBlockedOn = findInstanceBlockingEvent(nativeEvent);
if (nextBlockedOn === null) {
dispatchEventForPluginEventSystem(
domEventName,
eventSystemFlags,
nativeEvent,
return_targetInst,
targetContainer,
);
}
if (nextBlockedOn === blockedOn) {
break;
}
blockedOn = nextBlockedOn;
}
if (blockedOn !== null) {
nativeEvent.stopPropagation();
}
return;
}- Line 11 asks whether the event's target is inside something not hydrated yet. If not, lines 13 to 21 dispatch it as usual.
- Lines 24 to 35: hover and focus events are queued, and the event stops here. React replays the latest of each kind once the boundary hydrates.
- Lines 40 to 43: for a click, a key press and other discrete events (one-off actions, listed in ReactDOMEventReplaying.js), line 47 calls
attemptSynchronousHydrationon the boundary's fiber. - Lines 49 to 57: if that worked, the event is dispatched now, to the freshly hydrated button.
- Lines 59 to 66: if the boundary is still blocked, for example because its code hasn't downloaded, the event is stopped. It is lost, not replayed.
How does React know the target is blocked? findInstanceBlockingTarget starts from the clicked node and looks for the nearest fiber. Nodes inside a dehydrated boundary have none, so it finds the boundary's <!--$--> comment, which was linked to the Suspense fiber on the first pass. Shortened from lines 248 to 276:
export function findInstanceBlockingTarget(
targetNode: Node,
): null | Container | SuspenseInstance | ActivityInstance {
// …
return_targetInst = null;
let targetInst = getClosestInstanceFromNode(targetNode);
if (targetInst !== null) {
const nearestMounted = getNearestMountedFiber(targetInst);
// …
const tag = nearestMounted.tag;
if (tag === SuspenseComponent) {
const instance = getSuspenseInstanceFromFiber(nearestMounted);
if (instance !== null) {
// …
return instance;
}
// …
targetInst = null;Line 13: the nearest fiber is a Suspense fiber. Line 14 reads its dehydrated comment, and line 17 returns it: the event is blocked on that boundary. Then attemptSynchronousHydration, at ReactFiberReconciler.js, lines 486 to 512:
export function attemptSynchronousHydration(fiber: Fiber): void {
switch (fiber.tag) {
case HostRoot: {
const root: FiberRoot = fiber.stateNode;
if (isRootDehydrated(root)) {
// Flush the first scheduled "update".
const lanes = getHighestPriorityPendingLanes(root);
flushRoot(root, lanes);
}
break;
}
case ActivityComponent:
case SuspenseComponent: {
const root = enqueueConcurrentRenderForLane(fiber, SyncLane);
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, SyncLane);
}
flushSyncWork();
// If we're still blocked after this, we need to increase
// the priority of any promises resolving within this
// boundary so that they next attempt also has higher pri.
const retryLane = SyncLane;
markRetryLaneIfNotHydrated(fiber, retryLane);
break;
}
}
}Lines 14 to 16 schedule a render of that boundary at SyncLane, the most urgent lane, and line 18 runs it right away. A low-priority render in progress is thrown away and starts again later. Try it. The two boundaries in this page hydrate in one low-priority render, and SimilarTeas takes 1 second to render. Press Hydrate, then click Helpful:
import { hydrateRoot } from "react-dom/client";import Page from "./Page.jsx";import { note } from "./note.js"; const root = document.getElementById("root");const log = document.getElementById("log");document.addEventListener("click", (event) => { if (root.contains(event.target)) note(`The browser fires a click on "${event.target.textContent}"`);}, true); document.getElementById("hydrate").onclick = () => { note("hydrateRoot starts"); hydrateRoot(root, <Page />);}; document.getElementById("hydrate-and-click").onclick = () => { note("hydrateRoot starts"); hydrateRoot(root, <Page />); // Runs after a line is added to the log, once the code that added it stops. new MutationObserver((changes, observer) => { if (log.lastChild.textContent.startsWith("SimilarTeas renders")) { observer.disconnect(); root.querySelector("button").click(); } }).observe(log, { childList: true });};Lines 7 to 9 log each click the browser fires inside the root, before React sees it. Lines 11 to 14 hydrate when you press Hydrate the page. Lines 16 to 26 hydrate too, then click Helpful at a chosen moment. In Page.jsx, line 5 adds SimilarTeas renders to the log, and lines 6 to 9 keep the page busy for 1 second. The MutationObserver on line 20 is told about that new line only when the code that added it stops: when React yields after SimilarTeas, with Reviews not hydrated yet. Line 23 clicks then. The log says:
The browser fires a click on "Helpful (0)": the click arrives while the Reviews boundary is still dehydrated.Reviews hydrated, thenHelpful's onClick runs: React hydrated Reviews atSyncLane, then dispatched the click. The button saysHelpful (1).SimilarTeas rendersa second time, thenSimilarTeas hydrated: the interrupted render started over.
Press Run again, then Hydrate the page, and don't click: SimilarTeas hydrated comes first, then Reviews hydrated, in page order, and SimilarTeas renders once.
SimilarTeas is busy when you click Helpful. As soon as React yields, the click arrives, and React hydrates the Reviews boundary right away at SyncLane, then runs the click. The unfinished render starts again afterwards.How to read the table: each row is a kind of event that happens before its boundary hydrates, and what React does with it.
| The event | What React does |
|---|---|
| Clicks, key presses, input, and the other discrete events | attemptSynchronousHydration hydrates the boundary now, at SyncLane, then dispatches the event. If the boundary still can't hydrate, React stops the event and it's lost. |
focusin, mouseover, pointerover, dragenter, gotpointercapture | Queued, the latest of each kind, and replayed when the boundary hydrates (retryIfBlockedOn). |
A submit on a form whose action is a function | A small script the server sends with the page records it in $$reactFormReplay, and React replays it once the form hydrates. |
A framework can also ask for a boundary early, before any event. hydrateRoot returns a root with unstable_scheduleHydration(node), at ReactDOMRoot.js, lines 268 to 274. The unstable_ prefix means it may change in any release, so apps shouldn't rely on it:
function scheduleHydration(target: Node) {
if (target) {
queueExplicitHydrationTarget(target);
}
}
// $FlowFixMe[prop-missing] found when upgrading Flow
ReactDOMHydrationRoot.prototype.unstable_scheduleHydration = scheduleHydration;It calls queueExplicitHydrationTarget, at ReactDOMEventReplaying.js, lines 361 to 384:
export function queueExplicitHydrationTarget(target: Node): void {
const updatePriority = resolveUpdatePriority();
const queuedTarget: QueuedHydrationTarget = {
blockedOn: null,
target: target,
priority: updatePriority,
};
let i = 0;
for (; i < queuedExplicitHydrationTargets.length; i++) {
// Stop once we hit the first target with lower priority than
if (
!isHigherEventPriority(
updatePriority,
queuedExplicitHydrationTargets[i].priority,
)
) {
break;
}
}
queuedExplicitHydrationTargets.splice(i, 0, queuedTarget);
if (i === 0) {
attemptExplicitHydrationTarget(queuedTarget);
}
}Line 2 takes the priority of what is happening now, for example a hover. Lines 8 to 20 keep the queue sorted, most urgent first, and if the new target is first, line 22 raises the priority of its boundary's hydration. The queue is checked again each time a boundary finishes hydrating, in retryIfBlockedOn, the same function that replays queued hover and focus events.
A div inside a p
Here is a mismatch that surprises people: the component is the same on both sides, and it still fails. A product card wraps its content in a <p>:
import { hydrateRoot } from "react-dom/client";import ProductCard from "./ProductCard.jsx";import { watchChanges, watchCursor, watchLinks } from "./CursorSpy.js"; const root = document.getElementById("root");console.log("The page has:", root.innerHTML);const nodes = watchCursor(root);watchChanges(root);hydrateRoot(root, <ProductCard />, { onRecoverableError(error) { console.error(error.message.split("\n")[0]); },});watchLinks(root, nodes);In ProductCard.jsx, line 4 puts a <div> inside the <p> on line 3. The server writes exactly that, as the index.html tab shows. But HTML doesn't allow a <div> inside a <p>, so the browser's parser closes the <p> early. Line 6 of main.jsx prints what the page really holds: an empty <p class="card"></p>, the <div> and the button after it, and another empty <p> from the closing tag.
Now read the log with what you know:
<div id="root">.firstChild → <p>: the p fiber claims the empty<p>.<p>.firstChild → null: the cursor moves inside, and finds nothing. The div fiber has nothing to claim, sotryToClaimNextHydratableInstancethrows.- There is no Suspense boundary, so the whole root renders again: four nodes removed, one
<p>added. The console showsIn HTML, <div> cannot be a descendant of <p>(fromvalidateHydratableInstance, on lines 8 to 12 of the claim excerpt) and theHTMLmismatch.
The page looks right afterwards, because React built it with DOM calls, which allow any nesting. But the server's HTML was wasted. The fix is valid HTML: a <div> for the card, here in both ProductCard.jsx and the server's HTML:
import { hydrateRoot } from "react-dom/client";import ProductCard from "./ProductCard.jsx";import { watchChanges, watchCursor, watchLinks } from "./CursorSpy.js"; const root = document.getElementById("root");console.log("The page has:", root.innerHTML);const nodes = watchCursor(root);watchChanges(root);hydrateRoot(root, <ProductCard />, { onRecoverableError(error) { console.error(error.message.split("\n")[0]); },});watchLinks(root, nodes);Every node is claimed and linked; nothing is removed, and the console shows only the page. When a mismatch makes no sense, print the page's HTML before hydrating, as line 6 does, and compare it with what the server sent. Now sort the steps of hydration into the part of React that does them:
lane = getBumpedLaneForHydrationByLane(lane)enterHydrationState(workInProgress)fiber.stateNode = instancenextHydratableInstance = getFirstHydratableChild(instance)fiber.memoizedState = suspenseStateprecacheFiberNode(internalInstanceHandle, instance)getNextHydratableSibling(fiber.stateNode)const isDifferent = textNode.nodeValue !== textattemptSynchronousHydration(fiber)nativeEvent.stopPropagation()
Sort each line from this lesson's excerpts into the step of hydration it belongs to.
Common mix-ups
- "React compares the server's HTML string with its own." No. It never compares HTML. It walks the nodes with a cursor and checks each one when a fiber claims it.
- "Claiming checks the attributes." No.
canHydrateInstancecompares the tag only. Attributes are compared only in development, to warn, and the server's values stay. - "Hydration creates the elements again and swaps them in." No. Fibers take the server's nodes as their
stateNode. The marks example prints the same elements after hydrating. - "React hydrates everything in one go." No. The first pass steps over each Suspense boundary's content, and a later render at
OffscreenLanehydrates it. - "Clicks before hydration are queued and replayed." No. A click makes React hydrate its boundary at once; if that can't finish, the click is lost. Only hover, focus, and similar events are replayed, and form submits.
- "A mismatch only costs the element that differs." No. React throws away everything inside the nearest Suspense boundary, or the whole root.
Practice exercises
Predict first, then check with the live examples. The hydration exercises keep the server's HTML in the index.html tab: when you change what a component renders, change that HTML the same way, as the server would.
In the page with a Suspense boundary, the h1 fiber completes and popHydrationState reads <h1>.nextSibling. What does it return?
import { hydrateRoot } from "react-dom/client";import Page from "./Page.jsx";import { watchCursor, watchLinks } from "./CursorSpy.js"; const root = document.getElementById("root");const nodes = watchCursor(root);hydrateRoot(root, <Page />);watchLinks(root, nodes);The log's fourth line is <h1>.nextSibling → <!--$-->. The node after the heading is the comment that starts the Reviews boundary, and getNextHydratable stops there because it's a Suspense marker.
The server's HTML has <!--$!--><template></template><p>Loading...</p><!--/$-->. Does React hydrate that boundary's content? Answer yes or no.
export function isSuspenseInstancePending(instance: SuspenseInstance): boolean {
return (
instance.data === SUSPENSE_PENDING_START_DATA ||
instance.data === SUSPENSE_QUEUED_START_DATA
);
}
export function isSuspenseInstanceFallback(
instance: SuspenseInstance,
): boolean {
return (
instance.data === SUSPENSE_FALLBACK_START_DATA ||
(instance.data === SUSPENSE_PENDING_START_DATA &&
instance.ownerDocument.readyState !== DOCUMENT_READY_STATE_LOADING)
);
}No. isSuspenseInstanceFallback is true for $!, so mountDehydratedSuspenseComponent leaves DefaultLane work, and updateDehydratedSuspenseComponent renders the boundary's children in the browser without hydrating. The server's fallback is removed.
In the Reviews.jsx tab, change <p> to <blockquote> (both tags). Which word does the message use now: text or HTML?
import { hydrateRoot } from "react-dom/client";import Page from "./Page.jsx";import { watchChanges, watchCursor, watchLinks } from "./CursorSpy.js"; const root = document.getElementById("root");const nodes = watchCursor(root);watchChanges(root);hydrateRoot(root, <Page />, { onRecoverableError(error) { console.error(error.message.split("\n")[0]); },});watchLinks(root, nodes);import { hydrateRoot } from "react-dom/client";import Page from "./Page.jsx";import { watchChanges, watchCursor, watchLinks } from "./CursorSpy.js"; const root = document.getElementById("root");const nodes = watchCursor(root);watchChanges(root);hydrateRoot(root, <Page />, { onRecoverableError(error) { console.error(error.message.split("\n")[0]); },});watchLinks(root, nodes);HTML. The blockquote fiber can't claim the <p>: the tags differ, so canHydrateInstance returns null and tryToClaimNextHydratableInstance calls throwOnHydrationMismatch(fiber), with fromText false. The log has no <p>.firstChild and no linked <p> this time. The boundary is still all that's replaced.
Make the card valid HTML in ProductCard.jsx and in the server's HTML in index.html. How many nodes does React remove after your fix?
import { hydrateRoot } from "react-dom/client";import ProductCard from "./ProductCard.jsx";import { watchChanges, watchCursor, watchLinks } from "./CursorSpy.js"; const root = document.getElementById("root");console.log("The page has:", root.innerHTML);const nodes = watchCursor(root);watchChanges(root);hydrateRoot(root, <ProductCard />, { onRecoverableError(error) { console.error(error.message.split("\n")[0]); },});watchLinks(root, nodes);import { hydrateRoot } from "react-dom/client";import ProductCard from "./ProductCard.jsx";import { watchChanges, watchCursor, watchLinks } from "./CursorSpy.js"; const root = document.getElementById("root");console.log("The page has:", root.innerHTML);const nodes = watchCursor(root);watchChanges(root);hydrateRoot(root, <ProductCard />, { onRecoverableError(error) { console.error(error.message.split("\n")[0]); },});watchLinks(root, nodes);With a <div> in both files, the browser keeps the server's HTML as it was sent. Every fiber claims its node, so the log shows only reads and links: 0 nodes removed, and no mismatch in the console.
Take the Suspense boundary out of Page.jsx and its two comments out of index.html. Which element does the log say React removes?
import { hydrateRoot } from "react-dom/client";import Page from "./Page.jsx";import { watchChanges, watchCursor, watchLinks } from "./CursorSpy.js"; const root = document.getElementById("root");const nodes = watchCursor(root);watchChanges(root);hydrateRoot(root, <Page />, { onRecoverableError(error) { console.error(error.message.split("\n")[0]); },});watchLinks(root, nodes);import { hydrateRoot } from "react-dom/client";import Page from "./Page.jsx";import { watchChanges, watchCursor, watchLinks } from "./CursorSpy.js"; const root = document.getElementById("root");const nodes = watchCursor(root);watchChanges(root);hydrateRoot(root, <Page />, { onRecoverableError(error) { console.error(error.message.split("\n")[0]); },});watchLinks(root, nodes);removed <main>, then added <main>. The same text mismatch now has no Suspense boundary above it, so the root renders again without hydrating: React removes the server's <main>, with the heading and the button, and inserts a new one.
Make Reviews wait forever, as if its code were still downloading. Press Hydrate, then click Helpful. Does its onClick run?
import { hydrateRoot } from "react-dom/client";import Page from "./Page.jsx";import { note } from "./note.js"; const root = document.getElementById("root");const log = document.getElementById("log");document.addEventListener("click", (event) => { if (root.contains(event.target)) note(`The browser fires a click on "${event.target.textContent}"`);}, true); document.getElementById("hydrate").onclick = () => { note("hydrateRoot starts"); hydrateRoot(root, <Page />);}; document.getElementById("hydrate-and-click").onclick = () => { note("hydrateRoot starts"); hydrateRoot(root, <Page />); // Runs after a line is added to the log, once the code that added it stops. new MutationObserver((changes, observer) => { if (log.lastChild.textContent.startsWith("SimilarTeas renders")) { observer.disconnect(); root.querySelector("button").click(); } }).observe(log, { childList: true });};import { hydrateRoot } from "react-dom/client";import Page from "./Page.jsx";import { note } from "./note.js"; const root = document.getElementById("root");const log = document.getElementById("log");document.addEventListener("click", (event) => { if (root.contains(event.target)) note(`The browser fires a click on "${event.target.textContent}"`);}, true); document.getElementById("hydrate").onclick = () => { note("hydrateRoot starts"); hydrateRoot(root, <Page />);}; document.getElementById("hydrate-and-click").onclick = () => { note("hydrateRoot starts"); hydrateRoot(root, <Page />); // Runs after a line is added to the log, once the code that added it stops. new MutationObserver((changes, observer) => { if (log.lastChild.textContent.startsWith("SimilarTeas renders")) { observer.disconnect(); root.querySelector("button").click(); } }).observe(log, { childList: true });};No. The log shows the click, then SimilarTeas renders again and SimilarTeas hydrated, but never Helpful's onClick runs. attemptSynchronousHydration rendered the Reviews boundary at SyncLane, Reviews suspended, and findInstanceBlockingEvent returned the same boundary, so dispatchEvent stopped the event.
Check your understanding
For each question, ask where the cursor is and which fiber is looking at it.
Question 1 of 7Where does React keep the hydration cursor while it hydrates?
Choose an answer to see the explanation.
Question 2 of 7In the log,
<h1>.nextSibling → <p>comes beforelinked <h1> to the h1 fiber. Why?Choose an answer to see the explanation.
Question 3 of 7What does
canHydrateInstancecompare before a fiber claims an element?Choose an answer to see the explanation.
Question 4 of 7The server sent this Reviews boundary, and the browser renders
<p>5 stars from Asha</p>. Which message doesonRecoverableErrorget?Read the code, then predictHTML<!-- The HTML the server sent --> <main><h1>Green tea</h1><!--$--><p>4 stars from Asha</p><!--/$--><button>Add to cart</button></main>Choose an answer to see the explanation.
Question 5 of 7While hydrating, React finds
<!--$?-->at the cursor. What does it do?Choose an answer to see the explanation.
Question 6 of 7You click a button inside a Suspense boundary that isn't hydrated yet, and its code has loaded. What happens?
Choose an answer to see the explanation.
Question 7 of 7A page has no Suspense boundaries, and one paragraph's text differs. How much does React render again in the browser?
Choose an answer to see the explanation.
Key takeaways
- Hydration is a render with a cursor,
nextHydratableInstance, that walks the server's nodes in step with the fibers.enterHydrationStatestarts it at the container's first child. - In beginWork a fiber claims the node at the cursor if the tag matches, and the cursor moves inside. In completeWork the cursor moves to the next sibling, and
hydrateInstancestores the fiber and props on the node. - A tag that differs, text that differs, or nodes left over call
throwOnHydrationMismatch. React renders the nearest Suspense boundary, or the root, again without hydrating. Attributes only warn. - Suspense boundaries are comments:
<!--$-->,<!--$?-->,<!--$!-->, and<!--/$-->. The first pass steps over their content; a later render atOffscreenLanehydrates it. - A click inside a dehydrated boundary makes React hydrate it at
SyncLanefirst, then dispatch the click, or drop it if the boundary still can't hydrate.
Remember the one-liner.
Hydration renders as usual, but each fiber claims the server's node at the cursor instead of creating one.
Coming next: Server Components inside: the Flight protocol. It opens the format React uses to send rendered Server Components to the browser, row by row.