React
Two Trees: Hydration Explained
Reading level
The component that works in dev but breaks in prod
You build a date display component. It works perfectly in development. You deploy to production. Users see the page flicker, or see a timestamp that's wrong for a split second, or the browser console fills with warnings you never saw locally.
The culprit: hydration mismatch. The HTML your server rendered doesn't match what React rendered in the browser — and React throws away the server HTML and re-renders from scratch.
Hydration is the process where React takes server-rendered HTML and "attaches" event listeners and React state to it, making the page interactive without re-rendering everything. When the server HTML doesn't match what React would render client-side, React must discard the server HTML and render fresh — defeating the purpose of SSR.
React 18 changed hydration failure behavior: in strict mode, it throws a recoverable error and replaces the subtree. In production builds, it silently hydrates with the client render — users see a flash. Suppressing that flash requires ensuring the trees match at the point of hydration, not after. This is a correctness constraint, not a performance one.
Two trees that must match
When you use SSR (Next.js, Remix, etc.), your page loads in two phases:
- Server renders HTML — React runs on the server and generates HTML string. Browser receives and displays it immediately (fast first paint).
- React "hydrates" the HTML — React runs in the browser, builds its virtual DOM, and attaches to the server HTML.
These two runs must produce identical HTML. If they differ — even by one character — you have a mismatch. React either ignores it (silent, with a potential flash) or re-renders the whole subtree (visible flicker).
The two trees are: (1) the server VDOM, serialised to HTML, and (2) the client VDOM built during hydration. React walks both trees simultaneously. Any attribute or text node that differs triggers a warning in dev and a silent patch or subtree re-render in prod.
Common mismatch sources:
- Date/time:
new Date()returns different values on server and client - Random IDs:
Math.random()orcrypto.randomUUID()produce different values each run - localStorage/window reads: not available on server, available on client
- Browser extensions: inject HTML into the page, confusing React's tree walk
React 18's hydrateRoot API introduced better error boundaries for hydration
failures and the ability to suppress mismatches with suppressHydrationWarning
at the element level. React 19 extends this with full-document hydration improvements.
The architectural principle: mismatches should be intentionally suppressed, not accidentally
created.
Classic mismatch sources
The Date trap
// ❌ new Date() on server at 10:00:00 !== new Date() on client at 10:00:01
function LastUpdated() {
return <span>Updated: {new Date().toLocaleTimeString()}</span>;
}
The window trap
// ❌ window is undefined on server — crashes or renders differently
function ThemeProvider() {
const theme = window.localStorage.getItem('theme') || 'light';
return <div data-theme={theme}>{children}</div>;
}
The random ID trap
// ❌ Different UUID on server vs client → id mismatch
function FormField({ label }) {
const id = Math.random().toString(36).slice(2);
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}
All three share the same root: the value differs between the server execution context and
the client execution context. The component is pure (same props → same output) — but the
implicit inputs (Date.now(), window, Math.random())
aren't stable across contexts.
Browser extensions are a fourth source teams rarely account for: ad-blockers, password
managers, and accessibility tools inject HTML nodes that React doesn't expect. The correct
response is suppressHydrationWarning on the container element, not changing
your component tree.
Three clean fixes
Fix 1: useEffect for client-only values
// ✅ Server renders null; client renders the time after mount
function LastUpdated() {
const [time, setTime] = useState(null);
useEffect(() => {
setTime(new Date().toLocaleTimeString());
}, []);
if (!time) return null; // or a placeholder
return <span>Updated: {time}</span>;
}
Fix 2: stable IDs with React 18's useId
// ✅ useId generates the same stable ID on server and client
import { useId } from 'react';
function FormField({ label }) {
const id = useId();
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}
Fix 3: suppress intentional mismatches
// ✅ Tell React this element will differ — intentional, documented
<time dateTime={date.toISOString()} suppressHydrationWarning>
{date.toLocaleString()} {/* locale may differ server/client */}
</time>
useId() generates incrementing IDs that are consistent between server and
client because both start from the same component tree structure. It's the correct
replacement for Math.random() IDs in forms, tooltips, and ARIA associations.
The useEffect pattern works because React intentionally runs effects only on the client — the server render is "committed" HTML, and effects update it post-mount. The cost: a brief flash of the server value, acceptable for non-critical UI.
For theme-from-localStorage: the correct pattern is an inline FOUC script in
<head> that reads localStorage and sets a data attribute before React
hydrates. The React component then reads the attribute (already set by the script) —
both server and client render the same attribute value. This is how the portfolio's
theme system works.
Hydration timeline
What happens during SSR + hydration:
Try it: mismatch vs stable hydration
Toggle "Broken" to see a component using Math.random() — it renders differently each time (simulating server vs client mismatch). "Fixed" uses useId and stays stable.
Notice the ID values. Broken generates a new random ID on every "render" — the server and client IDs never match. Fixed uses a stable, incrementing ID.
In a real app, inspect the warning in Chrome DevTools Console (dev build): "Warning: Prop 'id' did not match. Server: 'xyz123'. Client: 'abc456'." That's the signal to investigate.
Showing: Fixed — stable useId
Debugging hydration mismatches
The warning text is your friend: React logs the expected vs received values.
Grep your component tree for Math.random(), Date.now(),
new Date(), and any window.* reads in render functions.
React 18 improved hydration error messages. Look for:
- "Warning: Text content did not match. Server: X, Client: Y"
- "Warning: Prop did not match. X Server: Y, Client: Z"
The message tells you which prop and which component. From there: check if that prop's
source is environment-dependent. Move it to a useEffect or use a stable
server-safe alternative.
Next.js 13+ adds a client-boundary with "use client" — server components
don't hydrate at all, which eliminates the mismatch category for those components.
For complex SSR apps, React 18's startTransition during hydration allows
the server HTML to remain interactive while React hydrates in the background —
a technique called "selective hydration." Mismatches in selectively-hydrated subtrees
are demoted to warnings rather than causing full re-renders, reducing the visible impact.
Metrics to track: CLS (Cumulative Layout Shift) — hydration mismatches are a top source of unexpected CLS in SSR apps. A spike in CLS correlates directly with hydration re-renders in production.
References
Remember
Key takeaways
-
Server-rendered HTML and React's client render must match exactly — any difference causes a mismatch warning and potential flicker.Hydration mismatches come from environment-dependent values in render: Date, Math.random(), window, localStorage.React 18 selective hydration demotes subtree mismatch to a warning; CLS spikes in production are a reliable signal for hydration re-renders.
-
Use
useId()instead ofMath.random()for stable IDs — React generates the same ID on server and client.Defer client-only values (Date, localStorage, window) to useEffect so they only run after hydration, not during it.suppressHydrationWarning is for intentional mismatches — document why, don't use it to paper over real mismatch bugs. -
The theme-from-localStorage FOUC fix is an inline script in head — sets a class before React runs, so server and client agree.React Server Components (Next.js App Router) don't hydrate — they eliminate this category for components that don't need client-side interactivity.Treat hydration mismatches like type errors — zero tolerance in CI (React's --verbose flag surfaces them as exit-code failures in test suites).
Keep going
Finish this takeaway, then continue the track — Casey saved your spot locally.
Sign in with email to sync progress across devices (beta).
Inside the Casebook
New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.
No spam. Unsubscribe anytime. Emails sent via Buttondown.
RSS feed