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>;
}
Blast radius of a hydration throw Two page mock-ups. On the left, labelled no scoped boundary, the entire page area is empty with a note that React discarded all server markup for the root. On the right, labelled scoped boundary, the header, article and footer remain rendered while a single panel shows a client-only re-render. no scoped boundary scoped boundary empty container all server HTML for the root discarded header — server HTML intact article — server HTML intact price panel — client-only re-render correct currency, one frame later footer — server HTML intact the boundary's position decides whether a currency mismatch costs one panel or the whole page

Step-by-step #

  1. 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.
  2. Render a placeholder on the failing pass. Returning null for one frame is what lets React finish unwinding cleanly before the client-only mount begins.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

Three frames from hydration failure to recovered UI Three panels in sequence. Frame one shows hydration throwing inside the panel. Frame two shows a placeholder occupying the same space. Frame three shows the client-only rendered panel with correct values. A note underneath states the surrounding page never re-rendered. frame 1 hydration throws frame 2 placeholder holds the space frame 3 client-only render, correct the header and body markup above and below the panel are never re-rendered total cost: one frame of a placeholder, not a blank page Two phases, two different causes Two labelled panels. The hydration phase panel lists causes such as server and client disagreeing on a value, an extension mutating markup, and locale differences. The client-render phase panel lists causes such as a genuine component bug that would fail without server rendering at all. phase: hydration · server and client computed different values · an extension mutated the markup first · locale, timezone or stored preference drift fix: make the first render deterministic phase: client-render · the component throws on its own data · the client-only retry failed too · nothing to do with server rendering fix: an ordinary component bug one boolean in the payload splits two failure families that otherwise look identical

Verification #

  1. Force the mismatch. Set a different currency in localStorage than the cookie the server reads, then load the page. The panel must recover client-side while everything around it stays put.
  2. 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.
  3. Check the report. One report with phase: hydration and a component stack naming the panel; a second failure on the client-only pass must be reported as client-render.
  4. 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.
  5. 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.