Claude Fable 5.1 & GPT-6 Astra packages are live

Hydration

Free

Hydration correctness — why server and client HTML must match, the values that reliably break it, and how to handle genuinely client-only content.

189 lines8.2 KB Minimax Frontend
targetModels
MiniMax M3MiniMax M2MiniMax M FamilyFuture MiniMax Models
name
hydration
category
Frontend
description
Hydration correctness — why server and client HTML must match, the values that reliably break it, and how to handle genuinely client-only content.
license
MIT
author
Agent.md maintainers
last-verified
reviewed-by
unreviewed
<!-- Generated from models/_canonical by scripts/build-model-variants.js. Edit the canonical source, not this file. Behavioural profile for MiniMax: scripts/model-profiles.json -->

#Scope contract

FILE_ISOLATION: Modify only files inside the scope the task names; report any out-of-scope change instead of making it.


#Purpose

Rules for keeping server-rendered HTML and client-rendered output identical.

Hydration attaches React to existing server markup instead of recreating it. When the two disagree, React discards the server tree and re-renders — you pay for server rendering, get none of its benefit, and users can see wrong content before the correction.


#The rule

Given the same props and state, the server and the first client render must produce identical output.

Everything below is a consequence. React does not compare the entire tree byte-for-byte in production, so a mismatch can be silent — wrong text that "corrects itself", a <details> that starts open, event handlers attached to the wrong node.


#What reliably breaks it

CauseWhyFix
new Date(), Date.now()Different instantsRender after mount, or pass a fixed timestamp as a prop
Math.random(), crypto.randomUUID()Different valuesuseId(), or generate on the server and pass down
window, document, navigatorUndefined on the serverEffect or useSyncExternalStore
localStorage (theme, preferences)Server cannot read itCookie, or a pre-hydration inline script
matchMedia, viewport sizeNo viewport on the serverCSS media queries, or useSyncExternalStore
Locale/timezone formattingServer locale differs from the user'sFormat on the client, or pass an explicit locale
Feature flags evaluated client-sideDifferent assignment per sideResolve on the server, pass down
Invalid HTML nestingThe browser repairs it before React sees itValid nesting
Browser extensionsThey mutate the DOM before hydrationNot fixable; ignore known cases

Invalid nesting is the one that surprises people: <p><div/></p> is auto-corrected by the parser, so the DOM no longer matches what React rendered. <div> inside <p>, <a> inside <a>, and anything but <tr> directly inside <table> all do this.


#Correct patterns

tsx
// 1. Genuinely client-only content: render a placeholder first.
//    Two renders, but no mismatch and no layout shift if the placeholder matches.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted ? <LocalTime value={ts} /> : <span suppressHydrationWarning>&nbsp;</span>;

// 2. External stores: the server snapshot must be deterministic
const theme = useSyncExternalStore(
  subscribe,
  () => localStorage.getItem("theme") ?? "light",   // client
  () => "light"                                     // server — always the same
);

// 3. Stable ids across the boundary
const id = useId();
return <><label htmlFor={id}>Email</label><input id={id} /></>;

For theme specifically, the two options that avoid a flash:

  • Store the preference in a cookie, read it server-side, and render the correct markup on the first pass. This is the only approach with no flash and no mismatch.
  • Or run a tiny inline script before hydration that sets a data-theme attribute from localStorage, and drive styling entirely from CSS so React never renders the difference.

suppressHydrationWarning silences the warning for one element's text content only. It is correct for a timestamp; it is not a general fix, and it does not make the mismatch go away — it only stops the message.


#Streaming and Suspense

With streaming SSR, hydration happens progressively per Suspense boundary. Two consequences:

  • A boundary whose fallback and content differ in size causes layout shift when it resolves. Size skeletons to match. → Frontend/performance
  • Interaction before hydration completes is queued by React (selective hydration prioritises the boundary the user touched), but only for React handlers. Native behaviour on an unhydrated custom control does nothing — which is a reason to prefer real <button> and <a href> elements.

Errors thrown during hydration surface as client errors, not server ones. Wrap risky subtrees in an error boundary so one broken widget does not blank the page.


#Detection

  • Mismatches are logged in development. Treat every hydration warning as a bug, not noise — the production consequence is silent.
  • Add an end-to-end test that loads key pages with JavaScript enabled and asserts no console error. → Testing/e2e
  • Test with a cold cache and a throttled connection, where the pre-hydration window is long enough to see the wrong content.
  • Check pages that depend on time, locale, authentication state and feature flags specifically — those are where mismatches concentrate.
html
<!-- The only theme approach with no flash and no mismatch: React never renders
     the difference, because CSS does. Runs before first paint, before hydration. -->
<script>
  (function () {
    var t = localStorage.getItem("theme");
    if (!t) t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
    document.documentElement.dataset.theme = t;
  })();
</script>
css
:root[data-theme="dark"] { --bg: #111; --fg: #eee; }

The alternative — storing the preference in a cookie and reading it in the server render — avoids the inline script entirely and is preferable where a session already exists.


#Anti-patterns

Anti-patternWhy it failsFix
new Date() in renderServer and client differPass a timestamp; format after mount
Math.random() for keys or idsDifferent per render passuseId() or server-generated
Reading window during renderUndefined on the serverEffect or external store
Theme read from localStorage in renderServer cannot see it; flash and mismatchCookie or pre-hydration script
useState + useEffect for an external storeTwo renders and a visible flashuseSyncExternalStore
Non-deterministic server snapshotMismatch by constructionConstant server snapshot
Client-side feature flag evaluationDifferent branch per sideResolve server-side
Invalid HTML nestingThe parser rewrites the DOMValid structure
suppressHydrationWarning as a fixHides the mismatch, does not remove itFix the cause
Ignoring hydration warningsProduction failure is silentTreat as a bug
Mis-sized Suspense fallbacksLayout shift on resolveMatch content dimensions
Custom controls instead of native elementsNo behaviour before hydration<button>, <a href>
No error boundary around risky subtreesOne failure blanks the pageAdd boundaries

#Checklist

  • Verify: Server and first client render produce identical output for the same props
  • Verify: No date, random value or browser global is evaluated during render
  • Verify: Generated ids come from useId()
  • Verify: External stores are read with useSyncExternalStore and a constant server snapshot
  • Verify: Theme and preferences come from a cookie or a pre-hydration script
  • Verify: Feature flags are resolved server-side and passed down
  • Verify: Locale and timezone formatting is explicit, not implicit
  • Verify: HTML nesting is valid throughout
  • Verify: suppressHydrationWarning is used only for single-element text, with a comment
  • Verify: Hydration warnings are treated as bugs and fixed
  • Verify: Suspense fallbacks match the dimensions of their content
  • Verify: Interactive elements are native so they work before hydration
  • Verify: Error boundaries wrap subtrees that may fail during hydration
  • Verify: Key pages are tested for console errors with a throttled connection