useMediaQuery — Subscribe to CSS media query matches in JavaScript
Recipe
import { useState, useEffect, useCallback } from "react";
/**
* useMediaQuery
* Returns `true` when the given CSS media query matches.
* SSR-safe: returns `defaultValue` on the server.
*/
function useMediaQuery(
query: string,
defaultValue: boolean = false
): boolean {
const [matches, setMatches] = useState<boolean>(() => {
if (typeof window === "undefined") return defaultValue;
return window.matchMedia(query).matches;
});
useEffect(() => {
if (typeof window === "undefined") return;
const mql = window.matchMedia(query);
setMatches(mql.matches);
const handler = (e: MediaQueryListEvent) => {
setMatches(e.matches);
};
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
/**
* Convenience hooks built on useMediaQuery
*/
function useIsMobile(breakpoint: number = 768): boolean {
return useMediaQuery(`(max-width: ${breakpoint - 1}px)`);
}
function useIsDesktop(breakpoint: number = 1024): boolean {
return useMediaQuery(`(min-width: ${breakpoint}px)`);
}
function usePrefersDarkMode(): boolean {
return useMediaQuery("(prefers-color-scheme: dark)");
}
function usePrefersReducedMotion(): boolean {
return useMediaQuery("(prefers-reduced-motion: reduce)");
}When to reach for this: You need responsive behavior in JavaScript that CSS alone cannot handle, such as conditionally rendering components, loading different data, or adjusting hook parameters based on screen size.
Working Example
"use client";
function ResponsiveNav() {
const isMobile = useIsMobile();
const prefersDark = usePrefersDarkMode();
const reducedMotion = usePrefersReducedMotion();
return (
<nav
style={{
background: prefersDark ? "#1a1a2e" : "#ffffff",
transition: reducedMotion ? "none" : "background 0.3s",
}}
>
{isMobile ? <HamburgerMenu /> : <DesktopMenu />}
</nav>
);
}
function HamburgerMenu() {
return <button aria-label="Menu">☰</button>;
}
function DesktopMenu() {
return (
<ul style={{ display: "flex", gap: 16, listStyle: "none" }}>
<li>Home</li>
<li>About</li>
<li>Contact</li>
</ul>
);
}
function AdaptiveGrid() {
const isDesktop = useIsDesktop();
const columns = isDesktop ? 3 : 1;
return (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: 16,
}}
>
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
);
}What this demonstrates:
useIsMobileconditionally renders a hamburger menu vs. desktop navigationusePrefersDarkModeapplies a theme without any CSS class togglingusePrefersReducedMotiondisables CSS transitions for users who prefer reduced motion- The grid layout adapts column count based on viewport width
- All hooks react in real-time to window resizes and system setting changes
Deep Dive
How It Works
window.matchMediacreates aMediaQueryListobject that evaluates a CSS media query string.- The
changeevent fires whenever the match state flips (e.g., the window crosses a breakpoint), triggering a state update. - SSR safety: The lazy initializer returns
defaultValuewhenwindowis not available. The effect only runs on the client. - Convenience hooks are thin wrappers that pass pre-built query strings, keeping component code clean.
Parameters & Return Values
| Parameter | Type | Default | Description |
|---|---|---|---|
query | string | — | Any valid CSS media query string |
defaultValue | boolean | false | Returned during SSR or when matchMedia is unavailable |
| Returns | boolean | — | Whether the query currently matches |
Variations
Multiple queries: For complex responsive logic, call the hook multiple times:
const isSm = useMediaQuery("(min-width: 640px)");
const isMd = useMediaQuery("(min-width: 768px)");
const isLg = useMediaQuery("(min-width: 1024px)");Breakpoint object: Return a named breakpoint for Tailwind-style usage:
function useBreakpoint() {
const isSm = useMediaQuery("(min-width: 640px)");
const isMd = useMediaQuery("(min-width: 768px)");
const isLg = useMediaQuery("(min-width: 1024px)");
const isXl = useMediaQuery("(min-width: 1280px)");
if (isXl) return "xl";
if (isLg) return "lg";
if (isMd) return "md";
if (isSm) return "sm";
return "xs";
}TypeScript Notes
- The return type is a plain
boolean, so no generics are needed. - Convenience hooks have fixed return types; they simply narrow usage.
- For the breakpoint variant, use a union return type:
"xs" | "sm" | "md" | "lg" | "xl".
Gotchas
- Hydration mismatch — The server renders with
defaultValue, but the client may evaluate to a different value. This can cause a flash. Fix: Accept the brief flash, or use CSS-based responsive design for layout-critical elements and reserveuseMediaQueryfor non-visual logic. - Too many listeners — Each call to
useMediaQuerycreates a separatematchMedialistener. Fix: This is fine for a handful of breakpoints. If you have dozens, consider a single listener with multiple breakpoints. - Invalid query string —
matchMediadoes not throw for invalid queries; it returns aMediaQueryListthat never matches. Fix: Validate queries during development. - iOS Safari quirks — Older versions of Safari use
addListener/removeListenerinstead ofaddEventListener. Fix: Modern Safari supports the standard API. For legacy support, add a fallback.
Alternatives
| Package | Hook Name | Notes |
|---|---|---|
usehooks-ts | useMediaQuery | Similar API, well-tested |
@uidotdev/usehooks | useMediaQuery | Minimal implementation |
ahooks | useResponsive | Returns breakpoint object |
react-responsive | useMediaQuery | Supports server-side rendering with hints |
| Tailwind CSS | Responsive classes | CSS-only, no JS needed |
FAQs
What does window.matchMedia return and how does the hook use it?
window.matchMedia(query)returns aMediaQueryListobject.- The object has a
matchesboolean and fires achangeevent when the match state flips. - The hook reads
matcheson mount and subscribes tochangefor live updates.
When should I use useMediaQuery instead of CSS media queries?
- When you need to conditionally render entirely different components (not just style changes).
- When you need to adjust hook parameters or fetch different data based on screen size.
- For pure styling changes, CSS media queries are faster and avoid hydration mismatches.
How do the convenience hooks (useIsMobile, usePrefersDarkMode, etc.) work?
They are thin wrappers that call useMediaQuery with a pre-built query string. For example, useIsMobile(768) calls useMediaQuery("(max-width: 767px)"). No extra logic is added.
Why is the breakpoint in useIsMobile set to breakpoint - 1?
max-width: 767px targets screens narrower than 768px. Using breakpoint - 1 ensures that exactly 768px wide is not considered mobile, matching common CSS breakpoint conventions.
Gotcha: My layout flashes on initial load because the server renders the wrong breakpoint. How do I fix this?
- The server uses
defaultValue(false), but the client may evaluate differently. - Accept the brief flash, or use CSS-based responsive design for layout-critical elements.
- Reserve
useMediaQueryfor non-visual logic like data fetching or feature toggles.
Gotcha: I passed an invalid media query string and the hook never returns true. Why no error?
window.matchMedia does not throw for invalid queries. It returns a MediaQueryList that never matches. There is no browser-level validation. Verify your query strings during development.
Does each call to useMediaQuery create a separate listener? Is that expensive?
Yes, each call creates its own matchMedia listener. For a handful of breakpoints this is fine. If you have dozens, consolidate into a single useBreakpoint hook that returns a named breakpoint string.
How would I type a useBreakpoint hook that returns a union of breakpoint names?
function useBreakpoint(): "xs" | "sm" | "md" | "lg" | "xl" {
const isSm = useMediaQuery("(min-width: 640px)");
const isMd = useMediaQuery("(min-width: 768px)");
const isLg = useMediaQuery("(min-width: 1024px)");
const isXl = useMediaQuery("(min-width: 1280px)");
if (isXl) return "xl";
if (isLg) return "lg";
if (isMd) return "md";
if (isSm) return "sm";
return "xs";
}Why does the hook accept a defaultValue parameter?
During SSR, window.matchMedia is not available. defaultValue provides a safe fallback so the hook returns a predictable boolean on the server. It defaults to false.
Can I detect user preferences like reduced motion with this hook?
Yes. Use the usePrefersReducedMotion convenience hook or pass the query directly:
const reducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");Related
- useWindowSize — track exact pixel dimensions
- useLocalStorage — persist theme preference
- useEventListener — the underlying listener pattern