react-errors-hydration
react-errors-hydration
Quick Reference
What Hydration Is
Hydration is the process where React attaches event handlers and component state to server-rendered HTML. React expects the server-rendered DOM to EXACTLY match what the client would render on its first pass. Any difference triggers a hydration mismatch error.
Server renders HTML string ──> Browser displays HTML (fast, non-interactive)
│
React hydrates ────────────────> Attaches event handlers, state, effects
│
Interactive application
ALWAYS ensure server and client render identical output on the first render pass. Hydration does NOT patch differences -- it assumes the DOM is correct and only attaches interactivity.
NEVER treat hydration warnings as harmless. In React 18, mismatches silently produce broken UI. In React 19, React attempts recovery but at a performance cost.
Hydration Mismatch Diagnostic Table
| Error Message / Symptom | Cause | Fix |
|---|---|---|
| "Text content does not match" | Different text on server vs client (Date, Math.random, locale) | Use useEffect + state for dynamic values |
"Expected server HTML to contain a matching <div> in <p>" |
Invalid HTML nesting (<div> inside <p>, <p> inside <p>) |
Fix HTML nesting to follow spec |
| "Hydration failed because the server-rendered HTML didn't match the client" | Conditional rendering based on client-only state | Use useEffect for client-only branches |
| "There was an error while hydrating but React was able to recover" (React 19) | Any mismatch -- React 19 reports and recovers | Fix root cause; recovery re-renders entire tree |
| Content flickers on page load | Mismatch causes React to discard server HTML and re-render | Identify and fix the mismatch source |
| Event handlers not working on server-rendered content | Hydration failed silently (React 18) | Check console for hydration warnings |
Extra attributes like data-* from browser extensions |
Extensions inject attributes after server render | Ignore if confirmed extension-caused; see Browser Extensions section |
"Prop className did not match" |
CSS-in-JS generating different class names server vs client | Configure SSR for your CSS-in-JS library |
Common Causes (Ranked by Frequency)
1. Date/Time Rendering
Problem: Server and client run at different times or timezones.
// BAD: Hydration mismatch -- server time !== client time
function Header(): JSX.Element {
return <span>{new Date().toLocaleTimeString()}</span>;
}
// GOOD: Render on client only via useEffect
function Header(): JSX.Element {
const [time, setTime] = useState<string>("");
useEffect(() => {
setTime(new Date().toLocaleTimeString());
}, []);
return <span>{time}</span>;
}
2. Browser-Only APIs (window, localStorage, navigator)
Problem: These APIs do not exist on the server.
// BAD: window is undefined on server
function Layout(): JSX.Element {
const width = window.innerWidth;
return <div>{width > 768 ? <Desktop /> : <Mobile />}</div>;
}
// GOOD: Detect client with useEffect
function Layout(): JSX.Element {
const [isClient, setIsClient] = useState<boolean>(false);
useEffect(() => {
setIsClient(true);
}, []);
if (!isClient) {
return <div><Desktop /></div>; // Server default
}
return (
<div>{window.innerWidth > 768 ? <Desktop /> : <Mobile />}</div>
);
}
3. Conditional Rendering Based on Client State
Problem: Authentication status, feature flags, or user preferences differ between server and client.
// BAD: isLoggedIn differs on server vs client
function Nav(): JSX.Element {
const isLoggedIn = checkAuth(); // Returns false on server, true on client
return isLoggedIn ? <UserMenu /> : <LoginButton />;
}
// GOOD: Start with server value, update on client
function Nav(): JSX.Element {
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
useEffect(() => {
setIsLoggedIn(checkAuth());
}, []);
return isLoggedIn ? <UserMenu /> : <LoginButton />;
}
4. Math.random and Non-Deterministic Values
// BAD: Different random value on server vs client
function Banner(): JSX.Element {
const id = Math.random().toString(36).slice(2);
return <div id={id}>Welcome</div>;
}
// GOOD: Use useId (React 18+) for stable identifiers
function Banner(): JSX.Element {
const id = useId();
return <div id={id}>Welcome</div>;
}
5. HTML Nesting Violations
ALWAYS follow HTML nesting rules. The browser auto-corrects invalid nesting BEFORE React hydrates, creating a DOM that does not match React's expected tree.
| Invalid Nesting | Browser Correction | Result |
|---|---|---|
<p><div>text</div></p> |
Splits into <p></p><div>text</div><p></p> |
Mismatch |
<a><a>link</a></a> |
Closes outer <a> before inner |
Mismatch |
<table><div>row</div></table> |
Removes <div> |
Mismatch |
<ul><div><li>item</li></div></ul> |
Restructures children | Mismatch |
6. CSS-in-JS Class Name Mismatch
CSS-in-JS libraries (styled-components, Emotion) generate class names at runtime. If the server and client use different generation strategies or ordering, class names differ.
ALWAYS configure your CSS-in-JS library for SSR following its official documentation. For styled-components, use ServerStyleSheet. For Emotion, use extractCriticalToChunks.
The isClient Pattern
The standard pattern for client-only rendering:
function useIsClient(): boolean {
const [isClient, setIsClient] = useState<boolean>(false);
useEffect(() => {
setIsClient(true);
}, []);
return isClient;
}
// Usage
function ClientOnlyFeature(): JSX.Element {
const isClient = useIsClient();
if (!isClient) {
return <Placeholder />; // MUST match server output
}
return <RichInteractiveWidget />;
}
NEVER use typeof window !== "undefined" as a render condition -- this evaluates to true during client-side rendering of SSR apps (hydration pass), causing the mismatch you are trying to avoid.
suppressHydrationWarning
// Acceptable: Timestamps that intentionally differ
<time suppressHydrationWarning>
{new Date().toISOString()}
</time>
// Acceptable: Third-party widget containers
<div suppressHydrationWarning id="third-party-widget" />
ALWAYS use suppressHydrationWarning ONLY on individual elements where the mismatch is intentional and harmless.
NEVER use suppressHydrationWarning as a blanket fix on parent containers. It only suppresses one level deep and masks real bugs.
NEVER use suppressHydrationWarning to hide mismatches you do not understand. Diagnose the root cause first.
hydrateRoot API
import { hydrateRoot } from "react-dom/client";
import App from "./App";
const root = hydrateRoot(
document.getElementById("root") as HTMLElement,
<App />,
{
onRecoverableError(error: unknown, errorInfo: { componentStack?: string }) {
// Log hydration mismatches to your error tracking service
console.error("Hydration error:", error);
console.error("Component stack:", errorInfo.componentStack);
},
}
);
ALWAYS provide onRecoverableError in production to track hydration issues. React 19 uses this callback for all recovered hydration mismatches.
React 19 Hydration Improvements
| Feature | React 18 | React 19 |
|---|---|---|
| Error messages | Generic "did not match" text | Full HTML diff showing server vs client output |
| Recovery | Discards entire server-rendered tree on mismatch | Attempts granular recovery, re-renders only affected subtree |
| Reporting | Console warning only | onRecoverableError callback with component stack |
| Third-party script interference | Silent failures | Better tolerance for extra attributes from extensions |
<style> and <link> in <head> |
Manual hoisting required | Native support; React deduplicates and hoists automatically |
React 19 Diff Output Example
Warning: Text content did not match.
Server: "Hello, World"
Client: "Hello, User"
at Greeting (app/components/Greeting.tsx:5:3)
at Layout (app/layout.tsx:12:5)
React 19 shows the EXACT server value vs client value plus the full component stack. Use this to trace the mismatch source directly.
Browser Extension Interference
Browser extensions (ad blockers, password managers, translation tools, accessibility plugins) inject or modify DOM elements AFTER server render but BEFORE React hydration.
Symptoms
- Hydration errors in production that are NOT reproducible locally
- Extra
<div>,<style>, ordata-*attributes in the DOM - Errors disappear in incognito mode
Diagnosis
- Open the page in incognito mode (extensions disabled)
- If the error disappears, an extension is the cause
- Use
onRecoverableErrorto log and filter these in production
Mitigation
- NEVER restructure your app to work around extension interference
- ALWAYS use
onRecoverableErrorto detect and filter extension-caused errors - Consider wrapping known injection targets with
suppressHydrationWarningONLY if confirmed extension-caused
Decision Tree: Fixing Hydration Errors
Hydration error detected
├── Is the content time-dependent (Date, timestamp)?
│ └── YES → Use useEffect + state OR suppressHydrationWarning
├── Does it use browser APIs (window, localStorage, navigator)?
│ └── YES → Use the isClient pattern or dynamic import with ssr: false
├── Is it conditional on user/auth state?
│ └── YES → Default to logged-out on server, update in useEffect
├── Is it an HTML nesting violation?
│ └── YES → Fix the HTML structure (no <div> in <p>, etc.)
├── Is it CSS-in-JS class names?
│ └── YES → Configure SSR for your CSS-in-JS library
├── Does it only happen in production with real users?
│ └── YES → Likely browser extension interference; use onRecoverableError
└── None of the above?
└── Check for: different environment variables, different API responses,
third-party scripts loading before hydration
Dynamic Import for Client-Only Components
When a component fundamentally cannot render on the server:
// Next.js
import dynamic from "next/dynamic";
const MapWidget = dynamic(() => import("./MapWidget"), {
ssr: false,
loading: () => <div>Loading map...</div>,
});
// Generic React with React.lazy (client-only, not for SSR)
const MapWidget = React.lazy(() => import("./MapWidget"));
ALWAYS provide a loading fallback that matches the server-rendered placeholder to prevent layout shift.
Reference Links
- references/examples.md -- Hydration error examples with complete fix patterns
- references/anti-patterns.md -- Common hydration mistakes and why they fail