A React Router hydration error means the HTML the server rendered differs from
what React renders in the browser on its first pass. In framework mode, React
Router hydrates the whole document, so a date in a route, a clientLoader that
returns other data, or an attribute an extension adds to <body> can each
cause it.
Common hydration errors in React Router
React Router has no hydration messages of its own. A React Router v7 hydration error, or one in a later version, is React's message, and with React 19 it reads:
Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client.
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up.React 18 words the same bugs differently; all React hydration error messages lists every variant, and production builds show a minified code such as #418.
Why does a React Router hydration mismatch happen?
The default entry.client.tsx calls hydrateRoot(document, <HydratedRouter />):
React owns <html>, <head> and <body>, and compares all of them. The
common causes:
| Cause | In React Router | Fix |
|---|---|---|
| Client data | A clientLoader with hydrate = true that returns other data than the loader | Below |
| The clock | new Date() or Date.now() in a route component | Time |
| Locale and timezone | toLocaleString() without an explicit locale or timeZone | Locale, timezone |
| Browser-only APIs | window, localStorage or matchMedia read during render | Browser APIs, storage |
| Invalid HTML | <div> inside <p>, <a> inside <a> | Invalid nesting |
| The document | Attributes an extension adds to <html> or <body>, tags a script adds to <head> | Extensions, scripts |
A common React Router v7 hydration mismatch comes from client data. The React
Router docs say it directly: without a HydrateFallback, the route component
is rendered on the server and the clientLoader runs during hydration, so the
loader and the clientLoader must return the same data on the first load.
How to fix a React Router hydration error
Render the same thing first
Render output that does not depend on the browser, then change it in
useEffect, which only runs in the browser after hydration:
import { useEffect, useState } from "react";
export default function Greeting() {
const [name, setName] = useState<string | null>(null);
useEffect(() => {
setName(localStorage.getItem("name"));
}, []);
return <p>Hello{name ? `, ${name}` : ""}</p>;
}For values the server knows (the user's locale from a cookie, the time of the
request), return them from the loader and render from loaderData on both
sides.
Render a fallback during hydration in React Router
When a route needs data that only the browser has, set
clientLoader.hydrate = true and export a HydrateFallback. React Router then
renders the fallback on the server and while the page hydrates, and renders
the route component only once the clientLoader has finished. The server and
the first client render agree, because both are the fallback.
import type { Route } from "./+types/game";
import { GameBoard, loadLocalGameData } from "../game";
export async function clientLoader() {
return loadLocalGameData();
}
clientLoader.hydrate = true as const;
export function HydrateFallback() {
return <p>Loading game…</p>;
}
export default function Game({ loaderData }: Route.ComponentProps) {
return <GameBoard data={loaderData} />;
}See the React Router guide to client data for the other patterns.
Keep suppressHydrationWarning for small, known differences
suppressHydrationWarning on an element tells React to keep the server's text
without reporting it, one level deep. It suits a timestamp whose first value
does not matter; everywhere else it hides a real bug. See
when suppressHydrationWarning is safe.
Test every route with hydration-proof
hydration-proof loads every route in a real browser and compares the server
HTML with the hydrated DOM. The react-router adapter is
picked when package.json has @react-router/dev or the project has a
react-router.config.* file. It supports framework mode; the package's
fixtures run version 8.4.
- Build and start:
react-router build(or yourbuildscript when it runsreact-router build), thenreact-router-serve ./build/server/index.js. The app is built only whenbuild/server/index.jsis missing. - Development mode:
react-router dev, with--mode developmentor--mode both. - Routes: from
react-router routes --json, inapp/orsrc/app/.:id,*and:lang?become[id],[...slug]and[[lang]], the syntaxroutes.dynamicuses. - Not-found page: a URL that does not exist is loaded too, to check that the not-found page hydrates.
- Navigation: with
--navigation, a client-side navigation throughwindow.__reactRouterDataRouter.navigateis compared with loading each route directly.
npm install -D hydration-proof
npx hydration-proof install
npx hydration-proof test --mode bothDynamic routes need example values, or they are skipped:
import { defineConfig } from "hydration-proof";
export default defineConfig({
routes: {
dynamic: { "/products/[id]": ["1", "42"] },
},
});If you serve the build with your own server instead of react-router-serve,
set server.command (and server.build, since a custom start command only
gets a build step when one is configured) and keep the react-router adapter
for its routes. The
ESLint plugin's recommended preset catches the clock, random
values and browser globals in route components as you type.
Related
- Remix hydration errors, React Router's predecessor
- Client-only components
- Test every route for hydration errors
- All causes of hydration errors
- Adapters