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:
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.
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
// 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:
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:
/* 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; }
}
<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:
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
);
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 windowinsideuseState— 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.
Common Mistakes to Avoid
- Seeding state from a measurement. No server-safe answer exists.
suppressHydrationWarningas 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.
Related
- SSR & Hydration Observer Safety — which globals exist during a server render
- Fixing IntersectionObserver is not defined in Next.js — the related error, and why ssr:false is a costly fix
- Why Callback Refs Beat useEffect for Observer Targets — attaching the observer after hydration, correctly
↑ Back to SSR & Hydration Observer Safety