Skip to the solution
HardFrontend7 min read

What causes hydration mismatches, and how do you handle them?

305 views

The problem

Question presented to candidate: "Your logs are full of hydration warnings. How do you work out what is causing them and what do you do about each kind?"

What a strong answer should cover:

  • A mismatch is the client's first render producing something different from the server HTML. React then discards the mismatched subtree and re-renders it on the client.
  • Not all mismatches are equal, which is the key insight: a text or structure mismatch is a recoverable error and the client value wins; an attribute mismatch only warns in the console and the server value is kept.
  • The causes fall into four groups: non-deterministic output (Date, Math.random, generated ids), environment differences (locale, timezone, window, localStorage), invalid HTML nesting the browser silently repairs, and third parties β€” browser extensions injecting markup.
  • The fix for a genuinely client-only value is a mounted flag: render the neutral version on the server and the first client render, then switch. Checking typeof window during render does not work β€” it makes the first client render differ, which is the mismatch.
  • For a store-backed value, useSyncExternalStore takes a server snapshot for exactly this.
  • suppressHydrationWarning is for one unavoidable node, such as a timestamp. It silences the warning and keeps the server value β€” it does not make the two agree.
  • Extension-caused mismatches are not your bug; recognise them rather than chasing them.

Clarifying questions expected:

  • "Is the differing value genuinely client-only, or just accidentally non-deterministic?" β€” different fixes.
  • "Does the warning reproduce in a clean profile with extensions disabled?"

Code / implementation expected: Optional. The mounted-flag pattern is three lines and worth writing out.

  • react
  • ssr
  • hydration

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

7 min read Β· 9 sections

Try it in the playground

Open any example in the playground to run and change it.

ReactFour mismatches triggered live, showing which value survives each
import { useState, useEffect } from "react";
import { renderToString } from "react-dom/server";
import { hydrateRoot } from "react-dom/client";

// Server renders one thing, client another. Each case reports whether React
// raised a recoverable error and WHICH value ended up in the DOM.
const CASES = [
  {
    name: "text mismatch",
    Server: () => <p>server text</p>,
    Client: () => <p>client text</p>,
    read: (host) => "text = " + JSON.stringify(host.textContent),
  },
  {
    name: "attribute mismatch",
    Server: () => <p className="from-server">same text</p>,
    Client: () => <p className="from-client">same text</p>,
    read: (host) => "class = " + JSON.stringify(host.querySelector("p").className),
  },
  {
    name: "extra element on client",
    Server: () => <div><span>a</span></div>,
    Client: () => <div><span>a</span><span>b</span></div>,
    read: (host) => "text = " + JSON.stringify(host.textContent),
  },
  {
    name: "suppressed mismatch",
    Server: () => <p suppressHydrationWarning>server value</p>,
    Client: () => <p suppressHydrationWarning>client value</p>,
    read: (host) => "text = " + JSON.stringify(host.textContent),
  },
];

export default function App() {
  const [rows, setRows] = useState([]);

  const run = async () => {
    const out = [];
    for (const c of CASES) {
      const host = document.createElement("div");
      // Produce the "server" HTML, exactly as a server would.
      host.innerHTML = renderToString(<c.Server />);
      document.body.appendChild(host);

      const errors = [];
      const warnings = [];
      const origErr = console.error;
      console.error = (...a) => warnings.push(String(a[0]));

      hydrateRoot(host, <c.Client />, {
        onRecoverableError: (e) => errors.push(String(e.message).split(".")[0]),
      });
      // Let hydration settle before reading the result.
      await new Promise((r) => setTimeout(r, 60));
      console.error = origErr;

      out.push({
        name: c.name,
        errors: errors.length,
        warnings: warnings.length,
        winner: c.read(host),
      });
      host.remove();
    }
    setRows(out);
  };

  useEffect(() => { run(); }, []);

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 580 }}>
      <button onClick={run} style={{ marginBottom: 12 }}>re-run</button>

      <table style={{ width: "100%", fontSize: 13, borderCollapse: "collapse" }}>
        <thead>
          <tr style={{ textAlign: "left", borderBottom: "1px solid #ccc" }}>
            <th>case</th><th>recoverable</th><th>warnings</th><th>what survived</th>
          </tr>
        </thead>
        <tbody>
          {rows.map((r) => (
            <tr key={r.name} style={{ borderBottom: "1px solid #eee" }}>
              <td style={{ padding: "4px 8px 4px 0" }}>{r.name}</td>
              <td style={{ color: r.errors ? "#a33" : "#161" }}>{r.errors}</td>
              <td style={{ color: r.warnings ? "#a60" : "#161" }}>{r.warnings}</td>
              <td><code style={{ fontSize: 12 }}>{r.winner}</code></td>
            </tr>
          ))}
        </tbody>
      </table>

      <p style={{ fontSize: 13, color: "#666" }}>
        Read the second row against the others. Text and structure mismatches
        raise a recoverable error and the <strong>client</strong> value wins.
        The attribute mismatch raises none β€” a console warning only β€” and the
        <strong> server</strong> class is kept. That is why a theme class that
        differs between server and client gets stuck on the server value and
        never shows up in your error reporting.
      </p>
    </div>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 108 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextHard

What are Server Actions and the `"use server"` directive in React 19?

Continue