Skip to content

useMediaQuery: hydration mismatch — initializer reads live window.matchMedia on first client render (SSR HTML always false) #175

Description

@agungadip

Current behavior

useMediaQuery seeds its state with a lazy initializer that reads the live matchMedia value on the client's first render:

// dist/index.mjs:10598-10609
function useMediaQuery(query) {
  const get = () => canUseDOM ? window.matchMedia(query).matches : false;
  const [matches3, setMatches] = React23__default.useState(get);
  React23__default.useEffect(() => {
    if (!canUseDOM) return void 0;
    const mql = window.matchMedia(query);
    const onChange = () => setMatches(mql.matches);
    onChange();                         // <-- already syncs the real value on mount
    mql.addEventListener("change", onChange);
    return () => mql.removeEventListener("change", onChange);
  }, [query]);
  return matches3;
}

canUseDOM is false during server render:

// dist/index.mjs:10551
var canUseDOM = typeof window !== "undefined" && typeof document !== "undefined";

So the server-rendered HTML is always built with matches = false. On the client's first render (hydration), the same get() initializer runs and returns window.matchMedia(query).matches. When the query currently matches, that is true — different from the false the server produced.

usePrefersReducedMotion (dist/index.mjs:10611) is a thin wrapper over useMediaQuery and inherits the bug, as does any responsive layout driven by this hook.

The type declaration advertises the hook as SSR-safe, which is inaccurate for the matching case:

// dist/index.d.ts:2114
/** Reactively match a media query (SSR-safe). */
declare function useMediaQuery(query: string): boolean;

Why it is a problem

This repo's consumer is a Next.js App Router (SSR) app. When a query matches at hydration time (e.g. a min-width breakpoint on a wide viewport, or prefers-reduced-motion: reduce), the first client render returns true while the server markup was false. React 19 detects a hydration mismatch: it logs a hydration warning and re-renders/patches the affected subtree, producing a visible flash (content appears in its non-matching layout, then snaps to the matching one). Every component that branches on useMediaQuery / usePrefersReducedMotion is affected, and the mismatch is silent to the author because it only manifests when the query happens to match on load.

Notably the mount useEffect already calls onChange() to push the correct value into state after hydration — so the eager read in the initializer is not needed for correctness. It is purely the cause of the mismatch.

Proposal

Return the server value on the first client render, then sync in the existing effect (the standard usehooks-ts pattern):

function useMediaQuery(query, { defaultValue = false, initializeWithValue = false } = {}) {
  const get = () => (canUseDOM ? window.matchMedia(query).matches : defaultValue);
  const [matches, setMatches] = React.useState(
    initializeWithValue ? get : defaultValue
  );
  React.useEffect(() => {
    if (!canUseDOM) return undefined;
    const mql = window.matchMedia(query);
    const onChange = () => setMatches(mql.matches);
    onChange(); // syncs the real value immediately after mount
    mql.addEventListener("change", onChange);
    return () => mql.removeEventListener("change", onChange);
  }, [query]);
  return matches;
}

Because the mount effect already re-reads and sets the value, defaulting the initial state to false (matching SSR) eliminates the hydration mismatch with no loss of reactivity — the correct value lands synchronously after mount. Optionally expose initializeWithValue/defaultValue for callers that knowingly opt into the client-eager read (non-SSR usage). At minimum, correct the (SSR-safe) JSDoc on dist/index.d.ts:2114 to document that a matching query causes a first-paint hydration mismatch.

twico-ui 1.4.0 · found during an exhaustive, source-grounded audit while building a production admin dashboard. Filed as part of a batch — feel free to merge/close any you consider covered.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingreleased

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions