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…

"use client"

import * as React from "react"

export interface UseMediaQueryOptions {
  /** Snapshot used during SSR and before hydration. Defaults to false. */
  serverFallback?: boolean
}

/**
 * Subscribe to a CSS media query and return whether it currently matches.
 * `useSyncExternalStore` wires `matchMedia`, an external data source, into the
 * React tree: subscribe attaches the `change` listener of `matchMedia(query)`,
 * getSnapshot reads `.matches`, and getServerSnapshot returns `serverFallback`

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-media-query.json

Prompt

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

  • useSyncExternalStore as 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 resize listenerresize fires on every pixel of window resize and says nothing about prefers-color-scheme or prefers-reduced-motion; matchMedia("...").addEventListener("change") only fires when the query's boolean result actually flips, for any media feature, not just viewport width.
  • serverFallback vs. hydration mismatch — the server can't run matchMedia (there's no viewport or OS to query), so it must render a guess; serverFallback is that guess. Whichever value you pick, the first client render matches it (avoiding a hydration mismatch warning), and the real value only appears after useSyncExternalStore reads the live snapshot on mount — a one-frame flash if the guess was wrong.
  • One matchMedia listener per query — each useMediaQuery(query) call owns an independent matchMedia instance 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.tsx ships a private useReducedMotion() that is exactly this pattern narrowed to one query (prefers-reduced-motion: reduce) with serverFallback hardcoded to false; reach for that shape when a component only ever needs one fixed query and doesn't want the general two-argument contract.

On This Page