This page adds a recovery layer to the diagnostics in Hydration Mismatch & State Recovery: what the user sees while you are still fixing the underlying mismatch.
The exact problem #
A pricing panel renders Intl.NumberFormat output using a currency read from a cookie on the server and from localStorage on the client. For most users the two agree. For users who changed currency in another tab, they do not — the server said £24.00, the client computes €28.00, and the element structure differs enough that hydration throws rather than warns.
React’s response to a hydration throw is severe by design: it cannot trust any of the server markup for that root, so it discards it and re-renders client-side. If that re-render also throws, or if nothing catches the error, the user is left looking at an empty page that was, milliseconds earlier, fully rendered HTML.
Zero-to-working fallback #
// ClientOnlyFallback.tsx
interface Props {
scope: string;
children: React.ReactNode;
/** Rendered while the client-only replacement is preparing. */
placeholder?: React.ReactNode;
}
interface State { failed: boolean; attempt: number }
export class HydrationSafe extends React.Component<Props, State> {
state: State = { failed: false, attempt: 0 };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
reportCrash({
scope: `hydration:${this.props.scope}`,
message: error.message,
componentStack: info.componentStack ?? undefined,
// These two fields are what make the report actionable.
phase: typeof window !== 'undefined' && !window.__HYDRATED__ ? 'hydration' : 'client-render',
digest: (error as { digest?: string }).digest,
});
}
componentDidUpdate(_prev: Props, prevState: State) {
// Re-mount once with a fresh key: a clean client-only render.
if (this.state.failed && prevState.attempt === this.state.attempt) {
this.setState((s) => ({ attempt: s.attempt + 1 }));
}
}
render() {
const { failed, attempt } = this.state;
if (failed && attempt === 0) return this.props.placeholder ?? null;
return (
<ClientOnlyContext.Provider value={failed}>
<React.Fragment key={attempt}>{this.props.children}</React.Fragment>
</ClientOnlyContext.Provider>
);
}
}
/** Components read this to skip server-derived assumptions on the retry. */
export const ClientOnlyContext = React.createContext(false);
// PricePanel.tsx — the consumer
export function PricePanel({ amount }: { amount: number }) {
const clientOnly = useContext(ClientOnlyContext);
// On the retry pass, read the authoritative client value directly instead of
// the server-provided one that caused the mismatch.
const currency = clientOnly ? readCurrencyFromStorage() : useServerCurrency();
return <output>{new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount)}</output>;
}
Step-by-step #
- Scope the boundary tightly. Wrap the components whose output depends on client-only values — locale formatting, timezone, stored preferences, feature flags read from storage. Wrapping the whole app just relocates the blank page.
- Render a placeholder on the failing pass. Returning
nullfor one frame is what lets React finish unwinding cleanly before the client-only mount begins. - Re-mount with a new key. Same component, fresh state, no server assumptions. Without the key change React reuses the failed fiber and can throw again immediately.
- Signal client-only mode through context. Components then choose the client-authoritative value on the retry instead of the one that disagreed with the server.
- Report with phase and component stack.
phase: 'hydration'versus'client-render'is the single most useful discriminator when triaging these, and it feeds the payload described in Building a Structured Error Payload for Crash Reports. - Fix the determinism. The fallback buys time; the cure is making server and client agree, or deferring the client-only value into an effect so the first render matches.
| Cause of mismatch | Right fix | Fallback needed? |
|---|---|---|
Date / relative time formatting |
Render an ISO string, format in an effect | Rarely |
| Locale or currency from storage | Send it from the server in the same request | Yes, while migrating |
| Feature flag read client-side | Resolve flags server-side and pass down | Yes |
window-dependent layout branch |
Use CSS media queries instead of JS branching | Rarely |
| Random ids | useId |
No |
| Browser extension mutating the DOM | Nothing you control | Yes — this is the classic case |
That last row is worth calling out: extensions that inject markup into your page can break hydration for a subset of users you cannot reproduce. A scoped fallback is the only practical mitigation, and it is why “just fix the determinism” is not a complete answer.
Verification #
- Force the mismatch. Set a different currency in
localStoragethan the cookie the server reads, then load the page. The panel must recover client-side while everything around it stays put. - Confirm the scope. Add a deliberate mismatch outside any
HydrationSafe; the whole root should blank — which is the behaviour you are protecting against, and proof the boundary is doing the work. - Check the report. One report with
phase: hydrationand a component stack naming the panel; a second failure on the client-only pass must be reported asclient-render. - Measure the cost. Record a performance trace; the placeholder frame should be a single frame with no layout shift, per Implementing Fallback Rendering Without Layout Shift.
- Test with an extension-style mutation. Inject a
<div>into the panel’s markup before hydration with a user script; the recovery path should handle it identically.
One caveat about scope: because the fallback re-renders its subtree on the client, anything expensive inside it — a large list, a chart, a heavy formatter — is paid for twice on affected devices. Keeping the guarded region small is therefore both a correctness decision and a performance one, and it is another reason to fix the underlying determinism rather than leaving the fallback as the permanent answer.
Frequently asked questions #
Why does one small hydration mismatch blank my whole page?
Because a hydration throw makes React distrust the server markup for that root, so it discards it and re-renders on the client. With no boundary, or a second failure, you are left with an empty container.
Is suppressHydrationWarning a fix?
It silences a warning for one element’s text, which is right for genuinely dynamic values. It does not prevent a crash from differing structure, and broad use hides real determinism bugs.
Should I just render everything client-only to be safe?
No — that trades a rare failure for a permanent regression in first paint and SEO. Scope client-only rendering to the values that cannot be known on the server.
Related #
- Hydration Mismatch & State Recovery — diagnosing why server and client disagree
- Fixing “Text Content Does Not Match” Hydration Errors in React — the warning-level version of the same problem
- Suppressing Hydration Warnings for Dates and Locales Safely — when silencing is legitimate