The warning names a class name and a DOM node, which sends people looking for a styling bug. The actual cause is almost always a piece of state whose initial value depends on something only a browser has.

Problem / Scenario Context

A reveal-on-scroll component keeps a boolean and adds a class when the element is in view. To avoid a flash of unrevealed content for elements already on screen, the initial state is seeded from a measurement:

TypeScript
const [visible, setVisible] = useState(() =>
  typeof window !== 'undefined'
    ? ref.current?.getBoundingClientRect().top < window.innerHeight
    : false,
);

The server renders class="panel". The first client render, on a device where the panel is above the fold, renders class="panel visible". React reports a mismatch, discards the server markup for that subtree and re-renders it — which is exactly the cost server rendering existed to avoid, plus a console warning.

The seeding was added to prevent a flash and instead causes a re-render of the subtree it was protecting.

Mechanics Explanation

Hydration is a comparison. React walks the server-rendered DOM and the first client render together, expecting them to describe the same tree. Any difference — an attribute, a class, a text node — means the client render cannot be reconciled with the markup, so React falls back to re-rendering that subtree from scratch.

The rule that follows is short: the first client render must be derivable without a browser. Anything that depends on viewport size, scroll position, matchMedia, localStorage, Date.now() or a random value will differ from what the server produced, because the server had none of those.

Observer-driven state is a textbook case, because there is no viewport during server rendering and therefore no correct answer to seed with. The only server-safe initial value is the one the server actually rendered — false — with the real value arriving after hydration.

The apparent cost is one frame of unrevealed content. In practice that is less than the cost of the mismatch, and it can be removed entirely with CSS, as below.

Server Markup, First Client Render, and the ComparisonThree panels across the hydration sequence. The server renders the element with a base class because no viewport exists. The first client render seeds state from a measurement and produces an additional class. React compares the two, finds a difference, discards the server subtree and re-renders it, logging a warning. The corrected path renders the base class on both sides and adds the class in an effect after hydration.server renderclass="panel"first client renderclass="panel visible"mismatchsubtree discarded and re-renderedserver renderclass="panel"first client renderclass="panel" — identicalhydrates, then the effect runsclass added one frame later

Comparison Table: Values That Cannot Seed Initial State

Source Server value Client value Safe to seed with
getBoundingClientRect() no element, no layout real geometry no
window.innerWidth undefined a number no
matchMedia(...).matches undefined true or false no
localStorage undefined a stored value no
Date.now() server clock client clock no
A prop from the server same same yes
A constant same same yes

Minimal Reproducible Example

TypeScript
// Warning: Prop `className` did not match.
// Server: "panel"  Client: "panel visible"
const [visible, setVisible] = useState(
  () => typeof window !== 'undefined' && window.scrollY === 0,
);
return <div className={visible ? 'panel visible' : 'panel'} />;

Production-Safe Solution

Seed from the server-safe default and let the observer move it:

TypeScript
function RevealPanel({ children }: { children: React.ReactNode }) {
  const [visible, setVisible] = useState(false);          // matches the server, always
  const cleanup = useRef<(() => void) | undefined>(undefined);

  const ref = useCallback((el: Element | null) => {
    cleanup.current?.();
    if (!el) return;
    const io = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) { setVisible(true); io.unobserve(entry.target); }
    }, { rootMargin: '0px 0px -10% 0px' });
    io.observe(el);
    cleanup.current = () => io.disconnect();
  }, []);

  useEffect(() => () => cleanup.current?.(), []);

  return <div ref={ref} className={visible ? 'panel visible' : 'panel'}>{children}</div>;
}

That removes the mismatch and reintroduces the flash the seeding was trying to prevent — for one frame, on elements already in view. Remove it in CSS rather than in state, so the fix costs nothing at hydration:

CSS
/* No JS yet, or reduced motion: content is simply visible. */
.panel { opacity: 1; }

/* Only once the script has marked the document does the reveal apply. */
.js .panel            { opacity: 0; transition: opacity 300ms ease; }
.js .panel.visible    { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .js .panel { opacity: 1; transition: none; }
}
HTML
<script>document.documentElement.classList.add('js');</script>

The inline script runs before first paint, so there is no flash of the pre-reveal state, and the class it adds is on <html> rather than on any hydrated subtree — which is why it does not itself cause a mismatch.

For the rarer case where a component genuinely must render differently on the client, useSyncExternalStore gives an explicit server snapshot rather than a typeof window guess:

TypeScript
const isWide = useSyncExternalStore(
  (cb) => { const mq = matchMedia('(min-width: 60rem)'); mq.addEventListener('change', cb);
            return () => mq.removeEventListener('change', cb); },
  () => matchMedia('(min-width: 60rem)').matches,   // client snapshot
  () => false,                                       // server snapshot — explicit and stable
);

Let CSS Own the Pre-Reveal StateTwo divisions of responsibility. In the first, React state decides both the initial appearance and the revealed appearance, so the initial value must be browser-derived and the mismatch follows. In the second, CSS owns the pre-reveal appearance keyed off a class added to the html element before paint, and React state only ever moves from false to true, which the server can render.state owns both appearancesinitial value must be measuredso it differs from the servermismatch, subtree re-renderand the warning names a class, not the causeCSS owns the pre-reveal state.js .panel { opacity: 0 }React state starts false on both sidesmarkup matches, no re-renderand no-JS readers see the content unhiddenThe html-level class is added before paint and is not part of any hydrated subtree, so it cannot cause a mismatch.

Verification Steps

  • Load with JavaScript disabled and confirm the content is visible; if it is hidden, the reveal is not progressively enhanced.
  • Check the console on a cold production build, not the dev server — some mismatches only appear in the production hydration path.
  • Diff the markup: view source for the server HTML and compare the element's attributes with the first client render.
  • Search the codebase for typeof window inside useState — it is the fingerprint of this bug.
  • Test above and below the fold, since the mismatch only occurs for elements the seeding would have marked visible.

Auditing for the PatternThree code searches that find hydration-unsafe initial state across a codebase: a typeof window check inside a useState initialiser, a matchMedia call at render time, and a localStorage read used to seed state. Each is paired with the safe replacement.Three greps that find every instanceuseState(() => typeof windowstart false; move it into an effectmatchMedia(…).matches at renderuseSyncExternalStore, with a server snapshotlocalStorage.getItem in an initialiserread it in an effect, or from an inline head script

Common Mistakes to Avoid

  • Seeding state from a measurement. No server-safe answer exists.
  • suppressHydrationWarning as the fix. It silences the message and keeps the re-render.
  • Hiding content in a base CSS rule. A reader without JavaScript then sees nothing at all.
  • Moving the component to a client-only dynamic import. It removes the mismatch by removing the server HTML, which is a much larger cost — see fixing IntersectionObserver is not defined in Next.js.

FAQ

Why does the warning mention className when the bug is in state?

Because the class attribute is where the difference becomes visible to the reconciler. React compares the rendered output, not the state that produced it, so the message names the attribute that differed. The cause is one level up: an initial state value that could not have been computed on the server.

Is suppressHydrationWarning an acceptable fix?

No. It silences the message while leaving the behaviour intact — React still discards the mismatched subtree and re-renders it, so you keep the cost and lose the diagnostic. It is intended for genuinely unavoidable differences such as a rendered timestamp.

How do I avoid a flash of unrevealed content without seeding state?

Move the pre-reveal appearance into CSS, keyed off a class added to the html element by an inline script before first paint. React state then only ever moves from false to true, which the server can render, and readers without JavaScript see the content unhidden.

What if a component genuinely must differ between server and client?

Use useSyncExternalStore, which takes an explicit server snapshot alongside the client one. That makes the difference deliberate and stable rather than accidental, and React knows to expect it instead of treating it as a reconciliation failure.


↑ Back to SSR & Hydration Observer Safety