← Recipes

阅读中文版本

Fix React Hydration Mismatches at the Source

Read the exact server-versus-client diff, find the nondeterministic render input, and isolate it with suppressHydrationWarning only as a last resort.

~30 minfrontendreactssr

Task

Diagnose the divergent node behind a hydration error, eliminate the nondeterminism, and verify the warning disappears without suppression.

When to use this

A page renders fine but logs hydration mismatches on every load. The team wrapped three components in suppressHydrationWarning and one layout bug still ships.

Prerequisites

  • React 18+ with the hydration error visible in console
  • Server rendering (SSR, SSG, or framework like Next/Remix)
  • Ability to compare server HTML against client render

Steps

1. Read the actual diff the error reports.

grep -o "server: .*" build/client/*.log 2>/dev/null | head -3 || echo "check devtools console: React prints server-rendered vs client-expected HTML fragments"

Expected output: React names the differing node and shows both fragments, for example a timestamp rendered as 14:03 on the server and 14:03:07 on the client.

2. Trace the divergent value to its render input.

// the classic sources, in order of frequency:

// 1. time
const now = new Date().toISOString();          // server time != client time

// 2. randomness
const id = Math.random().toString(36);          // different per process

// 3. environment
const isDev = process.env.NODE_ENV === 'development'; // can differ SSR vs browser

// 4. browser-only APIs during render
const width = window.innerWidth;                // crashes or undefined on server

Expected output: The source list maps each mismatch class to its fix: defer time to effect, seed randomness, gate browser APIs behind mounted state.

3. Fix by deferring the value to after mount, not by suppressing.

// before: mismatch on every load
function LastSync() {
  return <span>synced at {new Date().toLocaleTimeString()}</span>;
}

// after: stable server markup, fill in after mount
import { useEffect, useState } from 'react';

function LastSync() {
  const [at, setAt] = useState<string | null>(null);
  useEffect(() => {
    setAt(new Date().toLocaleTimeString());
    const t = setInterval(() => setAt(new Date().toLocaleTimeString()), 30_000);
    return () => clearInterval(t);
  }, []);
  return <span suppressHydrationWarning>{at ?? '…'}</span>;
}

Expected output: The server renders a stable placeholder, the effect fills the real time once, and the mismatch disappears because the first client render matches the server output.

4. For content that must differ, render deterministically and patch after.

// locale-dependent formatting: compute from a fixed source
// server and client agree on the raw value; only the format varies

function Price({ cents, currency }: { cents: number; currency: string }) {
  // Intl on server may use en-US; on client the visitor's locale
  // render with explicit locale, NOT the ambient one:
  const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency });
  return <span>{fmt.format(cents / 100)}</span>;
}

// if user-locale formatting is the product requirement:
function LocalPrice(props) {
  const [fmt, setFmt] = useState(new Intl.NumberFormat('en-US', { style: 'currency', currency: props.currency }));
  useEffect(() => {
    setFmt(new Intl.NumberFormat(navigator.language, { style: 'currency', currency: props.currency }));
  }, [props.currency]);
  return <span suppressHydrationWarning>{fmt.format(props.cents / 100)}</span>;
}

Expected output: Locale formatting is pinned to an explicit locale for the shared first render, then upgraded to the visitor locale after mount; the suppress scope covers one text node, not a subtree.

5. Verify the console is clean and no suppress calls were added carelessly.

rg -n "suppressHydrationWarning" src/ | wc -l; rg -n "suppressHydrationWarning" src/ | head -5; echo "then reload the page and confirm zero hydration warnings in console"

Expected output: A count near zero with each remaining instance justified by a deliberate post-mount patch, and a reload free of hydration errors.

Troubleshooting

Problem

Mismatch only appears in production, not dev

Solution

Dev double-render masks or reveals different paths; capture the production HTML with curl and run the client build against it locally to reproduce the exact divergence.

Problem

A third-party widget injects markup during render

Solution

Move the script into an effect with a ref container; vendor code that mutates the DOM during hydration is inherently nondeterministic and must be quarantined behind mount.

Problem

Invalid HTML nesting causes cascading mismatches

Solution

The browser reparents illegal nodes (a div inside p), so React compares a moved tree; fix the nesting first with the React error code as the guide, since later mismatches are noise until then.

Limitations

  • suppressHydrationWarning silences one level of text and attribute differences only; it never fixes structural mismatches and should mark known post-mount patches.
  • Deterministic first render is the durable contract; every accepted divergence is a tiny permanent exception that future code will copy.

The mismatch names the input; fix the input

Hydration compares the server HTML with the first client render and reports any difference. Every difference traces to a nondeterministic input: time, randomness, locale, or browser APIs. Fixing the input fixes the warning everywhere; suppressing it fixes the console only.

console error (React 18+):
  A tree hydrated but some attributes of the server-rendered HTML
   didn't match the client...
  - server: <span>synced at 14:03:11</span>
  - client: <span>synced at 14:03:11.412</span>   <-- new Date() at render

fix: render a stable placeholder, fill after mount
  server+client first paint: <span>synced at …</span>
  useEffect: setAt(new Date().toLocaleTimeString())

verification: reload x3, console clean,
  rg suppressHydrationWarning src/ -> 1 justified call

Suppress is a scalpel, not a blanket

wrap-everything-in-suppress is how layout bugs ship: the attribute disables React’s only check that server and client agree, for the subtree it covers. Scoped to a single span whose content is deliberately post-mount, it documents an accepted divergence. Anywhere else it is a blindfold.

Determinism is the SSR contract

The entire value of server rendering is that both sides compute the same tree from the same props. Time, randomness, and ambient locale break the contract at the input level, so the fix belongs at the input: explicit locale, seeded ids, or deferral to effects. Component-level patches treat symptoms of what is really a data-flow property.