Hooks
useMediaQuery
An SSR-safe hook that subscribes to a CSS media query and returns whether it currently matches.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-media-query.jsonPrompt
Build a React + TypeScript "useMediaQuery" hook (no dependencies beyond React;
uses the browser matchMedia API only).
Contract
- `useMediaQuery(query: string, options?: { serverFallback?: boolean }): boolean`.
- `serverFallback` defaults to `false` — the value returned during server
rendering and before hydration reads the real `matchMedia` result.
Behavior
- Built on `useSyncExternalStore`, not `useEffect` + `useState`:
- `subscribe(callback)`: create `window.matchMedia(query)`, register
`callback` on its `change` event, return a cleanup that unregisters it.
Guard for `typeof window === "undefined"` and return a no-op subscribe in
that case.
- `getSnapshot()`: return `window.matchMedia(query).matches`.
- `getServerSnapshot()`: return `serverFallback`.
- `query` can change between renders (a consumer might interpolate a prop into
the query string). `subscribe` and `getSnapshot` are both recreated when
`query` changes (memoize each with `useCallback` keyed on `query`), so
`useSyncExternalStore` tears down the old `matchMedia` listener and
subscribes to the new query automatically — no manual `useEffect` cleanup
dance required.
- No `useEffect` + `setState` anywhere: this is the exact case
`useSyncExternalStore` exists for — an external, mutable source
(`matchMedia`) that can change outside of React's render cycle (window
resize, OS theme toggle) and needs a tear-safe, hydration-safe subscription.
Rendering & styling
- The hook renders nothing itself — it returns a boolean. Consumers branch on
it directly (conditional JSX, conditional class via `cn()`) using semantic
tokens for anything visual; there's no styling contract to enforce inside
the hook.
Customization levers
- `serverFallback` — flip the SSR guess for a specific query (e.g. a query
you know is more often true than false in your traffic) to reduce the
hydration flash in one direction.
- A `useIsMobile()` (or `useBreakpoint("md")`) convenience wrapper around a
fixed set of project breakpoints is a reasonable addition on top — keep it
as a thin wrapper that calls `useMediaQuery` with a literal query string,
don't fold breakpoint constants into this hook's contract.
- Pairing with server-side UA sniffing (reading `User-Agent` in middleware or
a server component to pick a better `serverFallback` per request) is a
valid lever for reducing flash further, but it's an application-level
concern — this hook only exposes the `serverFallback` knob, it doesn't do
UA parsing itself.Concepts
useSyncExternalStoreas the matchMedia adapter — the hook doesn't poll or reconcile state by hand; it hands React the three primitives an external store needs (subscribe, snapshot, server snapshot) and lets React decide when to re-render, which also makes it tear-safe under concurrent rendering.- Why not a
resizelistener —resizefires on every pixel of window resize and says nothing aboutprefers-color-schemeorprefers-reduced-motion;matchMedia("...").addEventListener("change")only fires when the query's boolean result actually flips, for any media feature, not just viewport width. serverFallbackvs. hydration mismatch — the server can't runmatchMedia(there's no viewport or OS to query), so it must render a guess;serverFallbackis that guess. Whichever value you pick, the first client render matches it (avoiding a hydration mismatch warning), and the real value only appears afteruseSyncExternalStorereads the live snapshot on mount — a one-frame flash if the guess was wrong.- One
matchMedialistener per query — eachuseMediaQuery(query)call owns an independentmatchMediainstance and listener; calling it with many distinct query strings across a tree means many listeners, so prefer a small, stable set of queries (breakpoints, a couple of preference queries) over generating ad hoc query strings per render. - Specialization precedent — this repo's
animated-text.tsxships a privateuseReducedMotion()that is exactly this pattern narrowed to one query (prefers-reduced-motion: reduce) withserverFallbackhardcoded tofalse; reach for that shape when a component only ever needs one fixed query and doesn't want the general two-argument contract.