Server rendering and hydration
Render React components to HTML on the server, make that HTML interactive with hydrateRoot, and find and fix hydration mismatches in live examples.
- 01Explain server HTML and hydrationSay what renderToString and prerender put in the HTML, and what hydrateRoot does with it: adopt the elements and attach handlers, not rebuild them.
- 02Find and fix hydration mismatchesRead React 19's mismatch message and diff, tell which code made the server and the browser render differently, and fix it.
- 03Render browser-only content safelyChoose between page data, suppressHydrationWarning, a second render in an Effect, and use(browser()), and use Suspense to hydrate in parts.
A page you can see before its JavaScript arrives
Every React page so far started empty. Its index.html held <div id="root"></div>, and nothing showed until the JavaScript had downloaded and createRoot had filled the root. On a fast laptop that takes a moment. On a slow phone it can take seconds of blank screen.
Server rendering fixes the blank screen. A server runs your components first, turns what they return into HTML, and sends that HTML in the page. Here is a tiny server in Node.js for a tea shop's product page. It runs on a server, not in your browser, so it has no result here:
import { createServer } from "node:http";
import { renderToString } from "react-dom/server";
import ProductPage from "./ProductPage.jsx";
createServer((request, response) => {
const html = renderToString(<ProductPage />);
response.setHeader("Content-Type", "text/html");
response.end(`<!doctype html>
<html>
<body>
<div id="root">${html}</div>
<script type="module" src="/main.js"></script>
</body>
</html>`);
}).listen(3000);Line 2 imports renderToString from react-dom/server, the part of React that renders to HTML instead of to a page. For every request, line 6 calls it with <ProductPage /> and gets back a string of HTML. Lines 8 to 14 send a whole page: that HTML sits inside <div id="root"> on line 11, and line 12 loads the browser's JavaScript, main.js.
The browser shows the HTML as soon as it arrives. Then main.js loads and connects React to it. This example is that page. Its index.html tab holds the HTML the server sent, and its main.jsx is the browser's JavaScript. The result is live: click Add to cart.
import { hydrateRoot } from "react-dom/client";import ProductPage from "./ProductPage.jsx"; hydrateRoot(document.getElementById("root"), <ProductPage />);Line 1 imports hydrateRoot from react-dom/client. Line 4 gives it the root element, which already holds the server's HTML, and the same <ProductPage /> the server rendered. The count goes up when you click, and the console under the result shows Effect ran: the page is live. Open the ProductPage.jsx tab: it's an ordinary component with state on line 4 and an Effect on lines 5 to 7.
Connecting React to HTML that's already on the page is called hydration. The HTML is the dry page; the JavaScript is the water that brings it to life.
ProductPage and sends its HTML, so the visitor sees the page at once. When the JavaScript arrives, hydrateRoot runs the same component in the browser and attaches its event handlers to the elements already there.A new flat is handed over furnished: you see the sofa, the lamps and the switches the moment you open the door. Nothing works until the electrician connects the power. They don't rebuild anything; they check each switch against the plan and wire it up. If the plan says a switch should be where there isn't one, they have to stop and fix that wall.
- In real life: The builder furnishes the flat before you arrive
- In React: The server renders the HTML
- In real life: You walk in and see every room at once
- In React: The browser shows the page before the JavaScript loads
- In real life: The light switches do nothing until the power is on
- In React: The buttons do nothing until hydrateRoot runs
- In real life: The electrician connects the switches; nobody rebuilds the walls
- In React: Hydration attaches handlers to the elements already there
- In real life: A switch on the plan that isn't on the wall gets a call to the builder
- In React: A mismatch: React reports it and renders that part again
Server rendering runs your components on a server and sends their HTML, so the page shows before any JavaScript loads. Hydration runs the same components in the browser with hydrateRoot: React keeps the elements already on the page and attaches state and event handlers to them. Both runs must produce the same HTML.
Turning components into HTML
react-dom/server runs in a browser too, which makes it easy to watch. This example renders ProductPage to HTML inside the result frame and prints the string. It doesn't hydrate anything:
import { renderToString } from "react-dom/server";import ProductPage from "./ProductPage.jsx"; const html = renderToString(<ProductPage />);console.log(html);document.getElementById("root").textContent = html;Line 4 renders the component, line 5 prints the HTML to the console, and line 6 shows it as text in the result. Read it closely:
- The elements and their text are there:
<h1>Green tea</h1>and the price. - The count is
0, the starting value fromuseState(0)on line 4 ofProductPage.jsx. The server renders each component once, with its starting state. - The
onClickis gone. HTML can't hold a JavaScript function, so the button arrives with no handler. - The console shows no
Effect ran. Effects never run on the server: there is no page to change yet. <!-- -->appears twice inside the button. The button holds three pieces of text,Add to cart (, the count, and). A browser would read them as one, so React marks where each ends, and splits them again while hydrating.
Press Edit & run, open the ProductPage.jsx tab, and change useState(0) on line 4 to useState(2). The HTML now says Add to cart (<!-- -->2<!-- -->).
renderToString keeps what ProductPage shows on its first render: the elements, the text, and the starting count, 0. The click handler, the Effect, and setCount can't be written as HTML; they come back when the browser hydrates.renderToString returns right away, so it can't wait for data. This product page, in the ReviewsPage.jsx tab, loads its reviews with use inside a Suspense boundary, and the reviews take half a second:
import { renderToString } from "react-dom/server";import ReviewsPage from "./ReviewsPage.jsx"; console.log(renderToString(<ReviewsPage />));In ReviewsPage.jsx, lines 3 to 5 make a promise that gives the reviews after 500 ms, and line 8 waits for it with use. Line 4 of main.jsx renders the page with renderToString, and the console shows the fallback, Loading reviews..., between <!--$!--> and <!--/$-->. That marker tells the browser to render this boundary itself; in a development build, the template after it says why.
React 19 added prerender, in react-dom/static, which waits. It runs on a server or in a build step, so this code has no result here:
import { prerender } from "react-dom/static";
import ReviewsPage from "./ReviewsPage.jsx";
const { prelude } = await prerender(<ReviewsPage />);
const html = await new Response(prelude).text();
console.log(html);Line 4 waits until every Suspense boundary has its data, then gives back prelude, a stream of HTML, and line 5 reads it into a string. Half a second later line 6 prints <main><h1>Green tea</h1><!--$--><p>5 stars from Asha</p><!--/$--></main>: the reviews themselves, in a finished boundary, <!--$-->.
Read the table row by row: the API, what it gives you, and whether it waits for a component that suspends.
| API | What it gives you | Does it wait for Suspense data? |
|---|---|---|
renderToString (react-dom/server) | A string of HTML, at once. | No. A component that waits leaves its Suspense fallback in the HTML, for the browser to fill in. |
prerender (react-dom/static, React 19) | A stream of HTML, once everything has loaded. | Yes. It waits for every Suspense boundary, so the HTML is complete. Use it to build pages ahead of time. |
renderToPipeableStream, renderToReadableStream | HTML sent in pieces as each part is ready. | Yes, without holding up the rest: the next lesson, on streaming, covers them. |
React's docs recommend the streaming APIs for servers, and prerender for pages built ahead of time; renderToString still works and is the simplest to read, which is why this lesson uses it. Which way to render each page, on every request, ahead of time, or only in the browser, is a choice of its own: Rendering strategies compares them. In a real app, a framework like Next.js or React Router runs the server for you and compiles its JSX.
hydrateRoot adopts the server's HTML
hydrateRoot(domNode, reactNode, options) takes the element that holds the server's HTML, the same JSX the server rendered, and optional settings. React then does almost what createRoot does:
- It calls your components, with their starting state, exactly as the server did.
- Instead of creating an element for each one, it takes the next element already on the page and checks that it matches: the same tag, the same text.
- It keeps that element as the component's DOM node and attaches its event handling. React listens for events at the root, so the button's
onClickworks as soon as the button is matched. - Then it runs the Effects, as after any first render.
ProductPage as usual, but instead of creating elements it walks the ones on the page. Each one it renders must match the next one there; React keeps the page's element and attaches its event handling. Nothing is created and nothing moves.You can check that nothing is rebuilt. This example keeps the button the server sent before hydrating, then compares it with the button on the page afterwards:
import { hydrateRoot } from "react-dom/client";import ProductPage from "./ProductPage.jsx"; const root = document.getElementById("root");const serverButton = root.querySelector("button"); hydrateRoot(root, <ProductPage />); setTimeout(() => { const button = root.querySelector("button"); console.log("Same button the server sent:", button === serverButton);}, 500);Line 5 finds the server's button before React runs. Line 7 hydrates. Half a second later, line 10 finds the button on the page, and line 11 compares the two with ===, which is true only for the very same element. The console shows Same button the server sent: true, and the button counts your clicks.
Now press Edit & run and swap in createRoot: createRoot instead of hydrateRoot on line 1, and createRoot(root).render(<ProductPage />); on line 7. The console shows false: createRoot clears the root and builds new elements, throwing away the server's work. The reverse is wrong too: hydrateRoot is only for HTML that React rendered. For an empty root, use createRoot.
The examples run React 19.3.1, and these links point at that release. hydrateRoot starts at line 276 of ReactDOMRoot.js, next to createRoot. How React matches fibers with DOM nodes while hydrating is the subject of Hydration inside.
Between the HTML arriving and hydrateRoot finishing, the page looks ready but isn't. This example pretends main.js takes 3 seconds to download. Press Run again under the result, then click Add to cart straight away:
import { hydrateRoot } from "react-dom/client";import ProductPage from "./ProductPage.jsx"; // Pretend main.js takes 3 seconds to download on a slow phone.setTimeout(() => { hydrateRoot(document.getElementById("root"), <ProductPage />);}, 3000);Lines 5 to 7 wait 3 seconds before calling hydrateRoot. For those 3 seconds the button is plain HTML: clicks do nothing, and React never learns about them. Then the console shows Effect ran: the page is live, and clicks start to count from 0. Visitors on slow phones live in this gap. To keep it short:
- Send less JavaScript, and load code a part of the page needs only when it needs it.
- Hydrate in parts, so the important buttons work first. The last section shows how.
- Use plain links and
<form>elements whoseactionthe server handles: they work before any JavaScript has loaded.
When the server and the browser disagree
Hydration only works if the browser's first render produces the same HTML the server sent. When it doesn't, that's a hydration mismatch. This price note shows the time it was rendered:
import { hydrateRoot } from "react-dom/client";import PriceNote from "./PriceNote.jsx"; hydrateRoot(document.getElementById("root"), <PriceNote />, { onRecoverableError(error) { console.error(error.message); },});In the PriceNote.jsx tab, line 2 reads the clock and line 3 turns it into a time like 9:41. The server rendered at 9:41: look for it in the index.html tab. Your browser renders now, at a different time. The result shows your time, and the console shows React's message. Lines 4 to 8 of main.jsx pass an onRecoverableError option, which React calls for errors it recovers from; line 6 prints the message. Without the option, React reports these to the browser as uncaught errors.
Since React 19, a mismatch gives one message. It starts with what failed and lists the usual causes, then shows a diff: the components and elements down to the difference, with + before what the browser rendered and - before what the server sent. Older versions printed several separate warnings and no diff.
What React does next depends on what differs. Different text or a different element means React can't use that HTML, so it throws it away and renders that part again in the browser, up to the nearest Suspense boundary, or the whole root if there isn't one. A different attribute only gets a warning in development, and the element keeps the server's value. Try each case in the playground. The panes show the HTML the server sent, the page afterwards, which elements stayed, and React's exact words:
export default function PriceNote() {
const now = new Date();
const time = `${now.getHours()}:${String(now.getMinutes()).padStart(2, "0")}`;
return (
<main>
<h1>Green tea</h1>
<p>Prices checked at {time}</p>
</main>
);
}The server rendered at 9:41. The browser hydrates at 15:52.
onRecoverableError- The server sent
<main><h1>Green tea</h1><p>Prices checked at <!-- -->9:41</p></main>- The page afterwards
<main><h1>Green tea</h1><p>Prices checked at 15:52</p></main>- Elements the server sent that are still there
- None. React threw away all of the server's HTML and built every element again.
- What React printed
The text differs, so React can't use the server's HTML. With no Suspense boundary around the paragraph, it builds the whole root again in the browser and reports one error with a diff: + is what the browser rendered, - is what the server sent.
Start with Different text: nothing the server sent is kept. Then Text inside Suspense: the same mismatch now costs only the paragraph, and the <h1> stays. A different attribute keeps the wrong class on the page, and An extension adds an element shows that a mismatch isn't always your code. A production build does the same work but prints only a short error code, like Minified React error #418, so fix mismatches while you develop. Each row of the table is a usual cause, why the two renders differ, and the fix:
| The component reads | Why the server and the browser differ | How to fix it |
|---|---|---|
new Date(), Date.now() | The server renders at one time and the browser hydrates later. | Send the time the server used with the page, or show it after hydration. |
Math.random(), crypto.randomUUID() | Each call gives a new value. | Pick on the server and send it with the page. For ids that link a label to an input, use useId. |
typeof window, localStorage, window.innerWidth | The server has no window, so it takes the other branch. | Read them in an Effect, with use(browser()), or through useSyncExternalStore with a server snapshot. |
price.toLocaleString() with no locale | The server and the visitor can use different languages and time zones. | Pass the locale and time zone you mean, like toLocaleString("en-US"). |
A <div> inside a <p> | The browser's HTML parser moves it, so the page no longer has the shape React rendered. | Use elements that can nest, like a <span> inside the <p>. |
| A browser extension | It changes the page before React loads. | Nothing in your code. Hydrating the whole document lets React 19 skip what extensions add to <head> and <body>. |
Spaces or new lines inside <div id="root"> | The browser keeps them as text React never rendered. | Put the server's HTML right against the root's tags. |
Content only the browser knows
Some values really are different in the browser: the visitor's clock, their saved settings, the window's size. You can't make them match, but you can choose what the server renders and when the browser shows the real value. There are four ways.
1. Send the server's data with the page
If the server can know the value, make both sides render from the same data. Here the server decided the time, rendered it, and wrote it into the page as JSON, in a <script type="application/json"> after the root (see the index.html tab):
import { hydrateRoot } from "react-dom/client";import PriceNote from "./PriceNote.jsx"; // The server wrote the data it rendered with into the page.const data = JSON.parse(document.getElementById("page-data").textContent); hydrateRoot(document.getElementById("root"), <PriceNote checkedAt={data.checkedAt} />);Line 5 reads that JSON, and line 7 passes checkedAt to PriceNote, which now shows a prop instead of reading the clock. Both sides render 9:41, and the console stays empty. Frameworks do this for you: the data a page loaded on the server travels with its HTML.
2. suppressHydrationWarning, for one element
When the difference is small and either value is fine, put suppressHydrationWarning on the element:
import { hydrateRoot } from "react-dom/client";import PriceNote from "./PriceNote.jsx"; hydrateRoot(document.getElementById("root"), <PriceNote />, { onRecoverableError(error) { console.error(error.message); },});Line 7 of PriceNote.jsx now says <p suppressHydrationWarning>. The console stays empty, but look at the result: it says 9:41, the server's time. React doesn't fix the text, it only stops checking it. The attribute works one level deep, for the element's own text and attributes, not its children's. Keep it for rare cases like this timestamp.
3. A second render in an Effect
To show the browser's value, render something both sides agree on first, then switch in an Effect, which runs only in the browser, after hydration:
import { hydrateRoot } from "react-dom/client";import PriceNote from "./PriceNote.jsx"; hydrateRoot(document.getElementById("root"), <PriceNote />, { onRecoverableError(error) { console.error(error.message); },});In PriceNote.jsx, line 4 starts time as null, so the server and the first browser render both say Prices checked a moment ago (line 12). After hydrating, the Effect on lines 5 to 8 sets the real time, and React renders again: the result shows your time. The cost is a second render and a change the visitor can see.
4. use(browser()), for a component that only works in the browser
React 19.3 added browser to react-dom for this. A component that calls use(browser()) is skipped on the server, which sends the nearest Suspense fallback instead. In the browser, use(browser()) returns at once and the component renders while the page hydrates:
import { Suspense, use } from "react";import { browser } from "react-dom";import { hydrateRoot } from "react-dom/client"; // Pretend this visitor looked at two teas earlier.localStorage.setItem("recent", "Jasmine tea, Earl Grey"); function RecentlyViewed() { use(browser()); return <p>Recently viewed: {localStorage.getItem("recent")}</p>;} function ProductPage() { return ( <main> <h1>Green tea</h1> <Suspense fallback={<p>Loading your recent teas...</p>}> <RecentlyViewed /> </Suspense> </main> );} hydrateRoot(document.getElementById("root"), <ProductPage />);Line 2 imports browser. Line 9 calls use(browser()) before line 10 reads localStorage, which a server doesn't have. The index.html tab holds what the server sent: the fallback from line 17, Loading your recent teas..., inside a <!--$!--> marker. The result shows the teas right away, with no mismatch: React expected the browser to render this boundary. On the server, the component must be inside a <Suspense>. Outside one, the render fails with "The server render could not complete because client rendering was requested outside a Suspense boundary." Inside one, the skip isn't a mistake, so renderToPipeableStream, renderToReadableStream and prerender report it to their onBrowserBailout option instead of onError.
For browser values that change and need a subscription, like the window's width, use useSyncExternalStore with its third argument, getServerSnapshot, as in Subscribing to external stores. Each row below is a way to render a browser-only value, what the server's HTML shows, and when it fits:
| Way | What the server's HTML shows | When it fits |
|---|---|---|
| Send the data with the page | The real value, the same one the browser uses. | Whenever the server can know the value: a time it chose, a cart from the visitor's session. |
suppressHydrationWarning | The server's value, kept after hydration. | One element's text or attribute that may differ and doesn't matter, like a timestamp. |
| A second render in an Effect | A placeholder that the Effect replaces after hydration. | Values only the browser has, in any React 19 version. |
use(browser()), React 19.3 | The nearest Suspense fallback. | A whole component that can only render in the browser, like one that reads localStorage. |
useSyncExternalStore with getServerSnapshot | Whatever the server snapshot gives. | Browser values that change and need a subscription, like the window's width. |
Hydrating one part at a time
A Suspense boundary does more than limit what a mismatch costs. React hydrates each boundary on its own, so one slow part doesn't hold up the rest. This is called selective hydration. In this page, the reviews' code takes 3 seconds to download. Click Cart right away, then Helpful, then Helpful again after the console says Reviews hydrated:
import { Suspense, lazy } from "react";import { hydrateRoot } from "react-dom/client";import Header from "./Header.jsx"; const Reviews = lazy(() => slowDownload(import("./Reviews.jsx"))); // Pretends the reviews' code takes 3 seconds to download.function slowDownload(file) { return new Promise((resolve) => setTimeout(() => resolve(file), 3000));} function ProductPage() { return ( <main> <Header /> <Suspense fallback={<p>Loading reviews...</p>}> <Reviews /> </Suspense> </main> );} hydrateRoot(document.getElementById("root"), <ProductPage />);Line 5 loads Reviews.jsx with lazy, and lines 8 to 10 make the download take 3 seconds. Lines 16 to 18 put the reviews in a Suspense boundary. The index.html tab holds the server's HTML for both parts, the reviews between <!--$--> and <!--/$-->.
Line 23 hydrates. The header is ready at once: the console shows Header hydrated, and Cart counts. The reviews keep the server's HTML, so the visitor reads them, but Helpful does nothing for 3 seconds. When the code arrives, the console shows Reviews hydrated and Helpful counts too.
<Suspense> boundary lets React hydrate the page in parts. The header hydrates right away. The reviews keep the server's HTML until Reviews.jsx arrives, 3 seconds later, and only then respond to clicks.React also hydrates the part you use first. While some boundaries are still server HTML:
- If you click inside one whose code has arrived, React hydrates that boundary right away, then runs the click, so it isn't lost.
- If its code hasn't arrived, React can't, and in React 19.3.1 the click is dropped, as you saw with Helpful. For hover and focus, React keeps the latest event and replays it once the boundary hydrates.
You can read this rule in React's event code, in lines 196 to 223 of ReactDOMEventListener.js. With streaming, a boundary's HTML can arrive late too, after the rest of the page; Streaming server rendering covers it.
A shop header that hydrates cleanly
Here is a header from a real shop, written the way people often first write it. It greets a returning visitor by the name saved in localStorage, and shows their cart count. It checks typeof window because the server has no localStorage:
import { hydrateRoot } from "react-dom/client";import ShopHeader from "./ShopHeader.jsx"; // Pretend Asha visited before and left 3 teas in her cart.localStorage.setItem("name", "Asha");localStorage.setItem("cart", "3"); hydrateRoot(document.getElementById("root"), <ShopHeader />, { onRecoverableError(error) { console.error(error.message); },});In ShopHeader.jsx, lines 2 and 3 take one branch on the server, null and 0, and the other in the browser, Asha and 3. So the server sent Welcome and Cart (0), and the browser renders Welcome back, Asha and Cart (3). The console shows the mismatch on the heading, and React throws away the whole header and builds it again. The result looks right, but the server's HTML was wasted, and visitors see Cart (0) until the JavaScript arrives.
The two values need different fixes. The server knows the cart; only the browser knows the saved name:
import { hydrateRoot } from "react-dom/client";import ShopHeader from "./ShopHeader.jsx"; localStorage.setItem("name", "Asha"); // The server knew Asha's cart and wrote it into the page.const data = JSON.parse(document.getElementById("page-data").textContent); hydrateRoot(document.getElementById("root"), <ShopHeader cart={data.cart} />, { onRecoverableError(error) { console.error(error.message); },});The cart comes from the server. A real server reads it from the visitor's session; here it rendered Cart (3) and wrote {"cart":3} into the page. Line 7 of main.jsx reads it, and line 9 passes it as a prop, so both sides render Cart (3). The greeting is browser-only: in ShopHeader.jsx, Greeting calls use(browser()) on line 5, and lines 12 to 14 wrap it in a Suspense boundary whose fallback is the plain Welcome. The console stays empty, the server's button is kept, and the greeting appears as soon as the page hydrates.
<h1>{props.name}</h1><p>Items: {items.length}</p>price.toLocaleString("en-US")const id = useId();Math.random()new Date().getHours()typeof window === "undefined" ? "Hi" : "Hello"price.toLocaleString()
Sort each piece of render code: does it give the same result on the server and in the browser, or can the two differ?
Common mix-ups
- "Hydration renders the page again from scratch." No. It renders your components, but keeps the elements already on the page and attaches handlers to them. The adopt example logs
true. - "Server rendering makes the page work sooner." No. It makes the page show sooner. It works only once the JavaScript has loaded and hydrated it, so clicks before that do nothing.
- "suppressHydrationWarning fixes a mismatch." No. It hides the warning for one element and keeps the server's value. The page still says 9:41.
- "React corrects attributes that differ." No. It warns in development and leaves the server's attribute in place.
- "Effects run on the server too." No. Neither
useEffectnoruseLayoutEffectruns on the server, which is why a value set in an Effect is safe from mismatches. - "A
typeof windowcheck makes a component safe to render on the server." No. In render, it makes the server and the browser take different branches, which is a mismatch. - "Mismatches only matter in development." No. A production build throws away the HTML and renders again too; it just prints a short error code instead of the diff.
Practice exercises
Each hydration exercise has an index.html tab with the HTML the server sent, and a main.jsx that runs in the browser. Press Edit & run, change the code, and watch the result and the console. Guess first, then use the hints.
What HTML does line 8 print? Write it exactly, then press Edit & run to check in the console.
import { renderToString } from "react-dom/server";
function CartBadge() {
const count = 3;
return <p>Items: {count}</p>;
}
console.log(renderToString(<CartBadge />));import { renderToString } from "react-dom/server"; function CartBadge() { const count = 3; return <p>Items: {count}</p>;} console.log(renderToString(<CartBadge />));The paragraph holds two pieces of text, Items: and the number 3. React puts an empty comment, <!-- -->, between them, so the HTML is <p>Items: <!-- -->3</p>. Nothing follows the 3, so there's no second comment.
The visitor saved the dark theme. After hydration, what class does the link have? Answer, then press Edit & run and read the console.
import { hydrateRoot } from "react-dom/client";
import ThemeLink from "./ThemeLink.jsx";
// This visitor picked the dark theme on an earlier visit.
localStorage.setItem("theme", "dark");
hydrateRoot(document.getElementById("root"), <ThemeLink />);
setTimeout(() => {
console.log("The link's class:", document.querySelector("a").className);
}, 500);import { hydrateRoot } from "react-dom/client";import ThemeLink from "./ThemeLink.jsx"; // This visitor picked the dark theme on an earlier visit.localStorage.setItem("theme", "dark"); hydrateRoot(document.getElementById("root"), <ThemeLink />); setTimeout(() => { console.log("The link's class:", document.querySelector("a").className);}, 500);The server rendered class="light", because it has no localStorage. The browser renders className="dark". React warns that the attributes don't match and says it won't patch them, so line 10 prints light.
The server rendered the cart with two teas, but main.jsx hydrates it with an empty list, so the console reports a mismatch and the page shows Items: 0. Fix line 4. How many items does the page list after your fix?
import { hydrateRoot } from "react-dom/client";import Cart from "./Cart.jsx"; hydrateRoot(document.getElementById("root"), <Cart items={[]} />, { onRecoverableError(error) { console.error(error.message); },});import { hydrateRoot } from "react-dom/client";import Cart from "./Cart.jsx"; const data = JSON.parse(document.getElementById("page-data").textContent); hydrateRoot(document.getElementById("root"), <Cart items={data.items} />, { onRecoverableError(error) { console.error(error.message); },});Line 4 reads the JSON the server wrote, and line 6 passes data.items, the same two teas the server rendered with. The browser's first render matches, React keeps the server's HTML, and the page lists 2 items with nothing in the console.
Greeting greets by the hour, with a typeof window check on line 4, and the console reports a mismatch. Make it render Hello first and change in an Effect. What does the heading say while the page hydrates?
import { hydrateRoot } from "react-dom/client"; function Greeting() { if (typeof window === "undefined") { return <h1>Hello</h1>; } const hour = new Date().getHours(); return <h1>{hour < 12 ? "Good morning" : "Good afternoon"}</h1>;} hydrateRoot(document.getElementById("root"), <Greeting />, { onRecoverableError(error) { console.error(error.message); },});import { useEffect, useState } from "react";import { hydrateRoot } from "react-dom/client"; function Greeting() { const [greeting, setGreeting] = useState("Hello"); useEffect(() => { const hour = new Date().getHours(); setGreeting(hour < 12 ? "Good morning" : "Good afternoon"); }, []); return <h1>{greeting}</h1>;} hydrateRoot(document.getElementById("root"), <Greeting />, { onRecoverableError(error) { console.error(error.message); },});greeting starts as "Hello", the server's text, so hydration matches. The Effect on lines 6 to 9 runs only in the browser, after hydration, and sets the greeting for the visitor's hour. The heading says Hello until then.
The server rendered at 9:41 and your browser renders now. After hydration, is the <h1> the element the server sent? Answer yes or no, then press Edit & run.
import { Suspense } from "react";
import { hydrateRoot } from "react-dom/client";
function PriceNote() {
const now = new Date();
const time = `${now.getHours()}:${String(now.getMinutes()).padStart(2, "0")}`;
return (
<main>
<h1>Green tea</h1>
<Suspense fallback={<p>Checking prices...</p>}>
<p>Prices checked at {time}</p>
</Suspense>
</main>
);
}
const root = document.getElementById("root");
const serverHeading = root.querySelector("h1");
// An empty handler keeps the mismatch report out of the console.
hydrateRoot(root, <PriceNote />, { onRecoverableError() {} });
setTimeout(() => {
console.log("Same <h1>:", root.querySelector("h1") === serverHeading);
}, 500);import { Suspense } from "react";import { hydrateRoot } from "react-dom/client"; function PriceNote() { const now = new Date(); const time = `${now.getHours()}:${String(now.getMinutes()).padStart(2, "0")}`; return ( <main> <h1>Green tea</h1> <Suspense fallback={<p>Checking prices...</p>}> <p>Prices checked at {time}</p> </Suspense> </main> );} const root = document.getElementById("root");const serverHeading = root.querySelector("h1"); // An empty handler keeps the mismatch report out of the console.hydrateRoot(root, <PriceNote />, { onRecoverableError() {} }); setTimeout(() => { console.log("Same <h1>:", root.querySelector("h1") === serverHeading);}, 500);The paragraph's text differs, so React renders the boundary on lines 10 to 12 again in the browser. The <h1> on line 9 is outside it and was hydrated normally, so React kept the server's element: line 24 prints Same <h1>: true.
This header mismatches twice: its greeting reads localStorage in render (line 6), and line 15 passes the wrong cart. Fix both so the console stays empty. What does the cart button say?
import { hydrateRoot } from "react-dom/client"; localStorage.setItem("name", "Asha"); function ShopHeader({ cart }) { const greeting = typeof window === "undefined" ? "Welcome" : `Welcome back, ${localStorage.getItem("name")}`; return ( <header> <h1>{greeting}</h1> <button>Cart ({cart})</button> </header> );} hydrateRoot(document.getElementById("root"), <ShopHeader cart={0} />, { onRecoverableError(error) { console.error(error.message); },});import { useEffect, useState } from "react";import { hydrateRoot } from "react-dom/client"; localStorage.setItem("name", "Asha"); function ShopHeader({ cart }) { const [greeting, setGreeting] = useState("Welcome"); useEffect(() => { setGreeting(`Welcome back, ${localStorage.getItem("name")}`); }, []); return ( <header> <h1>{greeting}</h1> <button>Cart ({cart})</button> </header> );} const data = JSON.parse(document.getElementById("page-data").textContent); hydrateRoot(document.getElementById("root"), <ShopHeader cart={data.cart} />, { onRecoverableError(error) { console.error(error.message); },});With cart={data.cart} on line 21, both sides render Cart (3). With the greeting set in the Effect on lines 8 to 10, both first render Welcome. Hydration matches, so the console stays empty; then the Effect shows Welcome back, Asha. use(browser()), as in the practical example, works for the greeting too.
Check your understanding
For each question, think about two runs: what the server rendered, and what the browser renders while hydrating.
Question 1 of 7What does
hydrateRootdo with the<button>the server sent?Choose an answer to see the explanation.
Question 2 of 7What HTML does
renderToStringgive for this component?Read the code, then predictfunction CartBadge() { const count = 2; return <p>Cart: {count} items</p>; }Choose an answer to see the explanation.
Question 3 of 7A visitor taps Add to cart while
main.jsis still downloading. What happens to that tap?Choose an answer to see the explanation.
Question 4 of 7The server rendered
9:41, the browser renders15:52, and no Suspense boundary is around the paragraph. What does React 19 do?Choose an answer to see the explanation.
Question 5 of 7The server sent
class="light", and the browser rendersclassName="dark". What is on the page after hydration?Choose an answer to see the explanation.
Question 6 of 7A component calls
use(browser())inside a Suspense boundary. What does the server's HTML hold in its place?Choose an answer to see the explanation.
Question 7 of 7The reviews' code has loaded, but React hasn't hydrated their boundary yet. A visitor clicks Helpful. What happens?
Choose an answer to see the explanation.
Key takeaways
- Server rendering runs your components on a server and sends their HTML, so the page shows before the JavaScript loads. The HTML holds elements, text, and starting state; never handlers or Effects.
hydrateRootruns the same components in the browser and adopts the server's elements: it keeps them, attaches event handling, then runs Effects. Until it runs, the page looks ready but doesn't respond.- The browser's first render must match the server's HTML. React 19 reports a mismatch once, with a diff, and renders that part again up to the nearest Suspense boundary. Attributes that differ only get a warning and stay wrong.
- For values only the browser knows: send the server's data with the page, use
suppressHydrationWarningfor one harmless difference, set them in an Effect, or make the component browser-only withuse(browser()). - Suspense boundaries hydrate on their own. The rest of the page works while one boundary waits for its code, and a click on a boundary whose code is ready makes React hydrate it first.
Remember the one-liner.
The server sends what the page looks like; hydration makes those same elements work, as long as both render the same thing.
Coming next: Streaming server rendering. Instead of waiting for the whole page, the server sends the HTML in pieces, each Suspense boundary as soon as its data is ready.