Minified React error #418 means the HTML the server rendered did not match what the client rendered on its first pass. On my Next.js site the cause was a useMediaQuery hook that read window.matchMedia(query).matches inside a useState initializer. The server returned false, a desktop browser returned true, and React threw away the server HTML. Rewriting the hook on useSyncExternalStore with a getServerSnapshot of false made the first client render match the server by construction.

What does minified React error #418 mean?

React 19 reports it as "Hydration failed because the server rendered HTML didn't match the client." During hydration React walks the server HTML and the first client render side by side. If one has a node the other lacks, React cannot reuse the markup. It discards the server tree and renders the whole app again on the client.

In production the message is minified, so all you get is #418 and a link. It names no component and no node. On my site it fired on every production page load, and nothing looked broken, because the client re-render produced a correct page. The cost was invisible: the server HTML was thrown away, and every visitor paid for a full client render. I think this is what my earlier performance audit recorded as "mobile lab bound by react-dom hydration", but I have no before and after measurement to prove that link.

Why does reading matchMedia in useState cause a hydration mismatch?

The server has no window. A hook that wants to run on both sides has to pick a value for the server, and the natural choice is false. The mistake is reading the browser value during the first client render.

This is the shape of my broken hook. It is simplified, not the exact file I had:

import { useEffect, useState } from 'react' // Broken: the first client render reads the browser, the server cannot. export function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState(() => typeof window === 'undefined' ? false : window.matchMedia(query).matches ) useEffect(() => { const mql = window.matchMedia(query) const onChange = () => setMatches(mql.matches) mql.addEventListener('change', onChange) return () => mql.removeEventListener('change', onChange) }, [query]) return matches } 

On the server the initializer returns false. In a desktop browser the same initializer runs during the first client render and returns true. Hydration compares the two trees at that moment, so the value is already different before any effect runs.

The caller made it visible. My spotlight component draws a cursor-following overlay only when the device has a fine pointer:

const hasFinePointer = useMediaQuery('(pointer: fine)') // ... {hasFinePointer && ( <div ref={overlay} className="pointer-events-none fixed inset-0 z-30 ..." /> )} 

The server rendered no overlay. The desktop client rendered one extra child, so every sibling after it shifted position. That is enough for #418.

How do I find which component mismatches?

Production will not tell you. Run pnpm dev (or your package manager's dev command) and load the page. The development build of React prints the full, non-minified error, including a component stack with + and - lines that mark the exact mismatched node. The + side is what the client rendered and the - side is what the server sent. For my bug that diff pointed at the spotlight overlay div.

Two things to check once you have the component:

  1. Does it read window, document, localStorage, matchMedia or Date.now() while rendering, or in a useState initializer?
  2. Does it branch the JSX on that value?

If both are yes, you have found it. Other common causes of #418 are invalid HTML nesting and browser extensions that modify the DOM, but those did not apply here.

A second clue was sitting in my own code. A mounted guard already existed in a command palette trigger, with a comment explaining that the media query mismatched. The spotlight had no such guard. A per-caller workaround for a hook-level bug is the signal to fix the hook.

How do I fix it with useSyncExternalStore?

useSyncExternalStore takes three arguments: subscribe, getSnapshot and getServerSnapshot. From the React docs, getServerSnapshot is used during server rendering and during hydration on the client. That is the property that matters here. React does not call your client getSnapshot for the hydrating render, so the first client render sees the same false the server saw. Right after hydration finishes, React reads the real snapshot and re-renders if it differs. The docs describe this behaviour, and I did not trace it through React's source.

This is the hook as it is now in my repo:

import { useCallback, useMemo, useSyncExternalStore } from 'react' export function useMediaQuery(query: string): boolean { // One MediaQueryList per query, reused by both subscribe and getSnapshot. const mql = useMemo( () => (typeof window === 'undefined' ? null : window.matchMedia(query)), [query] ) const subscribe = useCallback( (onStoreChange: () => void) => { mql?.addEventListener('change', onStoreChange) return () => mql?.removeEventListener('change', onStoreChange) }, [mql] ) return useSyncExternalStore( subscribe, () => mql?.matches ?? false, () => false ) } 

Three details in it are worth copying:

  • The MediaQueryList is created once per query in useMemo. getSnapshot runs on every render, so calling matchMedia inside it would repeat work for no reason.
  • subscribe is wrapped in useCallback keyed on the list. A new function identity each render would make React unsubscribe and resubscribe every time.
  • The server snapshot is a constant false. That is the contract for callers: the first paint always has false, and the real value arrives in the commit right after.

Do I still need a mounted guard?

No. Once the hook returns false on the first client render, a mounted state flag adds nothing. I deleted the guard in the palette trigger. That component now hides itself on desktop with the Tailwind class sm:hidden, which is CSS and needs no JavaScript value at all.

This points at the cheaper option for many cases. If a media query only changes layout or visibility, use CSS. Keep useMediaQuery for the cases where JavaScript has to act on the result, such as attaching a mouse handler, as my spotlight does.

The trade-off is a one-frame flash. With a false server snapshot, a desktop user gets the touch layout first and the desktop layout after hydration. For my overlay that is invisible, since it is a faint gradient. For a layout that changes the page structure, pick a server default that suits your most common visitor, or use CSS.

How do I confirm the fix worked?

In production, the console on a fresh page load no longer shows #418; the only errors left there come from browser extensions. If it ever comes back, pnpm dev is where to look, since it prints the full hydration warning with its component stack.

One trap while checking: counting JSON-LD tags with a bare grep for application/ld+json double-counts, because the RSC payload embeds the component tree as an escaped string. Count real tags with grep -o '<script type="application/ld+json"' instead, or document.querySelectorAll in the browser.

The rule I took from it: never read window, matchMedia or localStorage in a useState initializer in a component that renders on the server. Use useSyncExternalStore with a server snapshot, or use CSS.

The site this came from is described in the portfolio case study. Another rendering bug from the same site, a 200 status on unknown slugs, is covered in Next.js soft 404: why notFound() returned 200 behind loading.tsx.