cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

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.

By the end, you can
  • 01
    Follow the hydration cursorExplain how fibers claim the server's nodes in beginWork, and how completeWork links them and moves the cursor on.
  • 02
    Explain what a mismatch costsSay what React checks while hydrating, what throwOnHydrationMismatch does, and why the nearest Suspense boundary decides how much is rendered again.
  • 03
    Read 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:

main.jsx: hydrating a product page with a spyPop out in the code editor (opens in a new tab)JSX
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);
ResultLoading…

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:

  1. React never creates an element. It reads the server's nodes one by one, with firstChild and nextSibling, in the same order it visits the fibers. That position is the hydration cursor.
  2. Every node gets linked to a fiber, so the server's <button> becomes the button React updates.
  3. 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.

Left, the server's nodes: main, then h1, p and button inside it. Right, the fibers React has made so far: main, h1 and p. Arrows labelled stateNode go from the main and h1 fibers to their nodes. A tag, the cursor, points at the p node. Beside the p fiber, a label says it claims the node at the cursor next. Beside the button node, a label says no fiber has reached it yet.the server's nodesfibers, this render<main>main fiber<h1>h1 fiber<p>p fiberstateNodeclaims next<button>not reached yetthe cursorpoints hereClaiming checks the tag. It doesn't make a node.
Halfway through the walk. The main and h1 fibers have claimed the server's <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.
Real-life analogyTaking stock in a furnished shop

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
The main idea

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:

createHydrationContainer schedules the first render, ReactFiberReconciler.js lines 340 to 348JavaScript
  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:

updateHostRoot enters hydration, shortened from ReactFiberBeginWork.jsJavaScript
    } 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 hydration state, ReactFiberHydrationContext.js lines 78 to 82JavaScript
// 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:

enterHydrationState, ReactFiberHydrationContext.js lines 162 to 179JavaScript
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;
}
  1. Lines 3 to 5: a renderer that can't hydrate, like React Native's, stops here. React DOM can. (Comments starting with $FlowFixMe tell Flow to skip a check; you can read past them.)
  2. 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>.
  3. Lines 10 and 11 make the root the hydration parent and switch hydration on.
  4. 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:

tryToClaimNextHydratableInstance, ReactFiberHydrationContext.js lines 461 to 484JavaScript
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);
  }
}
  1. Lines 2 to 4: outside hydration, there is nothing to claim. The fiber will create its node, as on any first render.
  2. 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.
  3. Line 14 reads the cursor. Lines 15 to 18: if there is no node, or tryHydrateInstance can't use it, line 22 throws a mismatch.

tryHydrateInstance, shortened from lines 251 to 291 (the cut lines collect differences for development warnings):

tryHydrateInstance, shortened from ReactFiberHydrationContext.jsJavaScript
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.

canHydrateInstance, simplified from ReactFiberConfigDOM.jsJavaScript
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;
}
  1. 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.
  2. Line 4 compares the tag: nodeName against the fiber's type, "h1". Nothing else. Text and attributes are checked later, or only for warnings.
  3. Line 7: a different tag means no claim. That's a mismatch.
  4. 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:

getNextHydratable, ReactFiberConfigDOM.js lines 4435 to 4461JavaScript
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;
}
  1. Lines 5 to 7: an element or a text node is a place to stop.
  2. 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.
  3. Lines 21 to 23: an end marker means there's nothing more at this level, so the cursor becomes null.
  4. 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:

main.jsx: are these the server's elements?Pop out in the code editor (opens in a new tab)JSX
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}`);  }};
ResultLoading…

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:

popHydrationState, shortened from ReactFiberHydrationContext.jsJavaScript
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;
}
  1. Lines 3 to 6: a fiber that isn't the current hydration parent wasn't hydrated, so there is nothing to do.
  2. Line 13 cuts a check for nodes left over inside this element: if the cursor isn't null here, the server sent more children than the fiber has, and that throws a mismatch too.
  3. 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:

hydrateInstance, ReactFiberConfigDOM.js lines 4579 to 4592JavaScript
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);
}
  1. Line 8, precacheFiberNode, stores the fiber on the node under the key __reactFiber$ plus a random string. That's the moment the spy logs linked. When you click, React starts from the clicked node and uses this key to find its fiber.
  2. Line 11, updateFiberProps, stores the props, with the onClick, under __reactProps$. From now on, React's listener at the root can call your handler: the button works.
  3. 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 returns false if the text differs.
Two rows. Top row, beginWork on the way down: 1, claim p, done by tryHydrateInstance; 2, move the cursor to its first child, done by getFirstHydratableChild; 3, its children, the same way. An arrow leads down to the bottom row, completeWork on the way up, read right to left: 4, move the cursor to the next sibling, done by popHydrationState; 5, link the fiber and props, done by hydrateInstance; 6, the next fiber's turn.beginWork, on the way down1. claim <p>2. firstChild3. its childrentryHydrateInstancegetFirstHydratableChildcompleteWork, on the way up4. nextSibling5. link fiber, props6. the next fiberpopHydrationStatehydrateInstancethe same again
One element, start to finish. On the way down, its fiber claims the node and the cursor moves inside it. After its children, on the way up, the cursor moves to the next sibling, and the node gets its fiber and props. That's why the log shows <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:

hydrateText, ReactDOMComponent.js lines 3384 to 3398JavaScript
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 hydration checks
WhatWhen React checks itIf it differs
The kind of node and its tagWhen 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 nodeWhen 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 elementWhen 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 classOnly 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.

Playground: step through the hydration walk
The server's nodes0 linked
  • <div id="root">
  • <main>this step
  • <h1>
  • "Green tea"
  • <p>
  • "In cart: "
  • <!-- -->
  • "0"
  • <button>
  • "Add to cart"
The spy's logline 1
  1. <div id="root">.firstChild → <main>
Step 1 of 18A page that matches
enterHydrationState

The render starts at the root fiber. updateHostRoot calls enterHydrationState, which points the cursor, nextHydratableInstance, at the root element's first child: <main>.

Each line is a real line from the spy's log: React 19.3.1 hydrated the page in a development build, and the lesson's test runs it again and checks every line. The words beside each line, and the function named, explain what React was doing; they're written for this lesson.

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):

throwOnHydrationMismatch, shortened from ReactFiberHydrationContext.jsJavaScript
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;
}
  1. Line 9 is the message you get, with one word chosen on line 6's fromText: text when a text check failed, HTML when nothing could be claimed.
  2. Line 15 queues that error. It isn't thrown at you: React reports it later, through onRecoverableError, because it can recover.
  3. 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:

throwException while hydrating, shortened from ReactFiberThrow.jsJavaScript
  // 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 {
  1. Line 7 finds the nearest Suspense boundary above the failing fiber. Lines 8 to 11, React's own comment, say the plan.
  2. Line 17 sets ForceClientRender on that boundary, and line 19 makes it catch the throw, like an error boundary would.
  3. Line 29 starts the case with no boundary. Then the whole root fails: when the render ends, recoverFromConcurrentError sets ForceClientRender on 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:

retrySuspenseComponentWithoutHydrating, ReactFiberBeginWork.js lines 2880 to 2906JavaScript
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:

main.jsx: a review that changed since the server renderedPop out in the code editor (opens in a new tab)JSX
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);
ResultLoading…

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:

The markers, ReactFiberConfigDOM.js lines 279 to 285JavaScript
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:

The server's markers, ReactFizzConfigDOM.js lines 4655 to 4663JavaScript
// 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:

main.jsx: the markers in real server outputPop out in the code editor (opens in a new tab)JSX
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));
ResultLoading…
  1. Line 15: two pieces of text side by side get <!-- --> between them. A browser would read In cart: 3 as one text node; the comment keeps them two, one for each text fiber.
  2. Line 16: a boundary whose content rendered is wrapped in <!--$--> and <!--/$-->.
  3. Lines 17 and 18: Waits (lines 10 to 13) suspends forever, and renderToString can't wait, so the server sends the fallback in <!--$!-->. Line 18 removes the long development-only message from the <template> so the line fits.
  4. Line 19: a visible <Activity> gets <!--&--> and <!--/&-->.
  5. Lines 21 to 23 stream the same waiting boundary and print the first chunk: <!--$?-->, the template with id B: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.

The comment markers in server HTML
MarkerWho writes itWhat 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:

tryHydrateSuspense, ReactFiberHydrationContext.js lines 348 to 381JavaScript
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;
}
  1. Lines 7 to 10 check that the cursor is at a boundary comment.
  2. Lines 13 to 19 store it in the fiber's memoizedState, under dehydrated. A Suspense fiber with memoizedState.dehydrated set is a dehydrated boundary: its content is on the page, but no fiber has claimed it.
  3. 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.
  4. 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:

mountDehydratedSuspenseComponent, ReactFiberBeginWork.js lines 2949 to 2970JavaScript
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:

main.jsx: hydrating a page with a Suspense boundaryPop out in the code editor (opens in a new tab)JSX
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);
ResultLoading…

Page.jsx wraps Reviews in a Suspense boundary on lines 8 to 10. Read the log in two parts:

  1. 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.
  2. Lines 15 to 21 are a second render, at OffscreenLane. It starts at <!--$-->.nextSibling, inside the boundary, and claims <p>. Reviews runs in the browser for the first time here.
A row of five server nodes: h1, the comment $, p, the comment /$, and button. Above, the Suspense fiber, with an arrow labelled memoizedState.dehydrated down to the $ comment. An arrow from above, labelled second pass starts here, points at p. Below the row, an arrow from the $ comment curves to button, labelled first pass: steps over the content.Suspense fibermemoizedState.dehydratedsecond pass starts here<h1><!--$--><p><!--/$--><button>first pass: steps over the content
On the first pass, the Suspense fiber claims <!--$-->, 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:

updateDehydratedSuspenseComponent, shortened from ReactFiberBeginWork.jsJavaScript
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;
    }
  1. Lines 14 to 24: <!--$!-->. The server gave up, so render it in the browser, without hydrating.
  2. Line 26 handles a boundary whose props or context changed before it could hydrate; the cut lines try a higher-priority lane first.
  3. Lines 28 to 35: <!--$?--> or <!--$~-->. The content is still streaming in. Leave it as it is (line 34) and try again when it arrives.
  4. 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:

isSuspenseInstancePending and isSuspenseInstanceFallback, ReactFiberConfigDOM.js lines 4319 to 4334JavaScript
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):

dispatchEvent, ReactDOMEventListener.js lines 157 to 224JavaScript
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;
  }
  1. Line 11 asks whether the event's target is inside something not hydrated yet. If not, lines 13 to 21 dispatch it as usual.
  2. 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.
  3. Lines 40 to 43: for a click, a key press and other discrete events (one-off actions, listed in ReactDOMEventReplaying.js), line 47 calls attemptSynchronousHydration on the boundary's fiber.
  4. Lines 49 to 57: if that worked, the event is dispatched now, to the freshly hydrated button.
  5. 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:

findInstanceBlockingTarget, shortened from ReactDOMEventListener.jsJavaScript
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:

attemptSynchronousHydration, ReactFiberReconciler.js lines 486 to 512JavaScript
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:

main.jsx: click a boundary while another one hydratesPop out in the code editor (opens in a new tab)JSX
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 });};
ResultLoading…

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:

  1. The browser fires a click on "Helpful (0)": the click arrives while the Reviews boundary is still dehydrated.
  2. Reviews hydrated, then Helpful's onClick runs: React hydrated Reviews at SyncLane, then dispatched the click. The button says Helpful (1).
  3. SimilarTeas renders a second time, then SimilarTeas 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.

Top left, SimilarTeas renders, at OffscreenLane, for 1 second. An arrow labelled React yields leads down to: click on Helpful, handled by attemptSynchronousHydration. An arrow leads right to: Reviews hydrates, at SyncLane. Then to: onClick runs. An arrow leads up to top right: SimilarTeas renders again, from the start.OffscreenLane, 1 secondSimilarTeas rendersReact yieldsclick on HelpfulattemptSynchronousHydrationReviews hydratesSyncLaneonClick runsSimilarTeas againrestarted from the beginning
The low-priority render of 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.

Events before hydration
The eventWhat React does
Clicks, key presses, input, and the other discrete eventsattemptSynchronousHydration 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, gotpointercaptureQueued, the latest of each kind, and replayed when the boundary hydrates (retryIfBlockedOn).
A submit on a form whose action is a functionA 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:

scheduleHydration, ReactDOMRoot.js lines 268 to 274JavaScript
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:

queueExplicitHydrationTarget, ReactDOMEventReplaying.js lines 361 to 384JavaScript
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>:

main.jsx: a card with a div inside a pPop out in the code editor (opens in a new tab)JSX
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);
ResultLoading…

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:

  1. <div id="root">.firstChild → <p>: the p fiber claims the empty <p>.
  2. <p>.firstChild → null: the cursor moves inside, and finds nothing. The div fiber has nothing to claim, so tryToClaimNextHydratableInstance throws.
  3. There is no Suspense boundary, so the whole root renders again: four nodes removed, one <p> added. The console shows In HTML, <div> cannot be a descendant of <p> (from validateHydratableInstance, on lines 8 to 12 of the claim excerpt) and the HTML mismatch.

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:

main.jsx: the same card as a divPop out in the code editor (opens in a new tab)JSX
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);
ResultLoading…

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:

Which part of hydration does it?
  • lane = getBumpedLaneForHydrationByLane(lane)
  • enterHydrationState(workInProgress)
  • fiber.stateNode = instance
  • nextHydratableInstance = getFirstHydratableChild(instance)
  • fiber.memoizedState = suspenseState
  • precacheFiberNode(internalInstanceHandle, instance)
  • getNextHydratableSibling(fiber.stateNode)
  • const isDifferent = textNode.nodeValue !== text
  • attemptSynchronousHydration(fiber)
  • nativeEvent.stopPropagation()
Try it yourself
0 of 10 correct

Sort each line from this lesson's excerpts into the step of hydration it belongs to.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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. canHydrateInstance compares 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 OffscreenLane hydrates 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.

Exercise 1 · Warm-upThe read after the heading

In the page with a Suspense boundary, the h1 fiber completes and popHydrationState reads <h1>.nextSibling. What does it return?

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upA boundary the server gave up on

    The server's HTML has <!--$!--><template></template><p>Loading...</p><!--/$-->. Does React hydrate that boundary's content? Answer yes or no.

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeText or HTML?

      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);
      ResultLoading…

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFix the card

        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);
        ResultLoading…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeNo boundary to stop it

          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);
          ResultLoading…

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeA click on a boundary that can't hydrate

            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 });};
            ResultLoading…

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask where the cursor is and which fiber is looking at it.

              The hydration internals quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Where does React keep the hydration cursor while it hydrates?

                Choose an answer to see the explanation.

              2. Question 2 of 7In the log, <h1>.nextSibling → <p> comes before linked <h1> to the h1 fiber. Why?

                Choose an answer to see the explanation.

              3. Question 3 of 7What does canHydrateInstance compare before a fiber claims an element?

                Choose an answer to see the explanation.

              4. Question 4 of 7The server sent this Reviews boundary, and the browser renders <p>5 stars from Asha</p>. Which message does onRecoverableError get?

                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.

              5. Question 5 of 7While hydrating, React finds <!--$?--> at the cursor. What does it do?

                Choose an answer to see the explanation.

              6. 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.

              7. 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. enterHydrationState starts 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 hydrateInstance stores 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 at OffscreenLane hydrates it.
              • A click inside a dehydrated boundary makes React hydrate it at SyncLane first, 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.

              CompleteFrontend Clear concepts. Working examples.