React useMediaQuery Hook
Mirroring CSS media queries inside React components keeps logic about responsive breakpoints, dark-mode preference, and reduced motion in one place. The useMediaQuery hook subscribes to a MediaQueryList so renders stay in sync with the browser. This snippet covers the basic match boolean, an SSR-safe variant with an initial fallback, and a useBreakpoint helper that maps named breakpoints onto Tailwind-style logic.
720 views
5
function useMediaQuery(query) {
const getMatch = () => {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(query).matches;
};
const [matches, setMatches] = useState(getMatch);
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return undefined;
const mql = window.matchMedia(query);
const onChange = (e) => setMatches(e.matches);
mql.addEventListener('change', onChange);
setMatches(mql.matches);
return () => mql.removeEventListener('change', onChange);
}, [query]);
return matches;
}
function useState(init) {
const v = typeof init === 'function' ? init() : init;
return [v, () => {}];
}
function useEffect(fn) { fn(); }
const isWide = useMediaQuery('(min-width: 768px)');
console.log('wide?', isWide);window.matchMedia(query) returns a MediaQueryList whose matches property reflects the current state and which fires a change event whenever the match flips. Reading the initial state synchronously inside the lazy initializer means the first render already has the right value (no flicker), and resyncing on subscribe handles the rare case where the value flipped between mount and effect-run. This is the right shape for prefers-color-scheme, prefers-reduced-motion, and any breakpoint.
function useMediaQuerySSR(query, fallback = false) {
const [matches, setMatches] = useState(fallback);
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return undefined;
const mql = window.matchMedia(query);
const onChange = (e) => setMatches(e.matches);
setMatches(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, [query]);
return matches;
}
const prefersDark = useMediaQuerySSR('(prefers-color-scheme: dark)', false);
console.log('dark mode preferred?', prefersDark);Reading window.matchMedia during render breaks SSR because window does not exist on the server, and reading it during the first client render breaks hydration when the result differs from the server-rendered HTML. Returning fallback synchronously and updating inside useEffect keeps both sides happy: the server and first client render produce identical markup, and the actual match flips in afterward. This is the version to ship with Next.js, Remix, or Astro.
const BREAKPOINTS = {
sm: '(min-width: 640px)',
md: '(min-width: 768px)',
lg: '(min-width: 1024px)',
xl: '(min-width: 1280px)',
};
function useBreakpoint(name) {
return useMediaQuery(BREAKPOINTS[name]);
}
console.log('lg?', useBreakpoint('lg'));Most teams settle on a small named set of breakpoints rather than memorising raw min-width queries. Wrapping useMediaQuery in a useBreakpoint(name) helper centralises the table so design tokens and code stay in lockstep. The helper is intentionally thin so renaming a breakpoint changes one constant rather than every call site. For more complex needs (smallest matching breakpoint, current viewport name) compose this hook with a fallback chain or a small reducer over all four results.
