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.
Current behavior
useMediaQueryseeds its state with a lazy initializer that reads the livematchMediavalue on the client's first render:canUseDOMis false during server render:So the server-rendered HTML is always built with
matches = false. On the client's first render (hydration), the sameget()initializer runs and returnswindow.matchMedia(query).matches. When the query currently matches, that istrue— different from thefalsethe server produced.usePrefersReducedMotion(dist/index.mjs:10611) is a thin wrapper overuseMediaQueryand 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:
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-widthbreakpoint on a wide viewport, orprefers-reduced-motion: reduce), the first client render returnstruewhile the server markup wasfalse. 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 onuseMediaQuery/usePrefersReducedMotionis affected, and the mismatch is silent to the author because it only manifests when the query happens to match on load.Notably the mount
useEffectalready callsonChange()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):
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 exposeinitializeWithValue/defaultValuefor 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.