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.

193 lines8.4 KB Deepseek Frontend
targetModels
DeepSeek V4DeepSeek V3.2DeepSeek R1DeepSeek V3 FamilyFuture DeepSeek 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 DeepSeek: scripts/model-profiles.json -->

#Task boundary

  1. Implement exactly the task as stated. Do not add abstractions, options, config, or files the task did not name.
  2. Comments, identifiers, commit messages and log strings are English only.
  3. Stop when the checklist at the end passes. Do not refactor or "improve" surrounding code.
  4. Every checklist item below is backed by an assertion in a test or by pasted command output, never by a sentence.

#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:

  1. 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.
  2. 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:

  1. A boundary whose fallback and content differ in size causes layout shift when it resolves. Size skeletons to match. → Frontend/performance
  2. 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

  1. Mismatches are logged in development. Treat every hydration warning as a bug, not noise — the production consequence is silent.
  2. Add an end-to-end test that loads key pages with JavaScript enabled and asserts no console error. → Testing/e2e
  3. Test with a cold cache and a throttled connection, where the pre-hydration window is long enough to see the wrong content.
  4. 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

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