React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

media-queryresponsivedark-modemobilessrcustom-hook

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:

  • useIsMobile conditionally renders a hamburger menu vs. desktop navigation
  • usePrefersDarkMode applies a theme without any CSS class toggling
  • usePrefersReducedMotion disables 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.matchMedia creates a MediaQueryList object that evaluates a CSS media query string.
  • The change event fires whenever the match state flips (e.g., the window crosses a breakpoint), triggering a state update.
  • SSR safety: The lazy initializer returns defaultValue when window is 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

ParameterTypeDefaultDescription
querystringAny valid CSS media query string
defaultValuebooleanfalseReturned during SSR or when matchMedia is unavailable
ReturnsbooleanWhether 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 reserve useMediaQuery for non-visual logic.
  • Too many listeners — Each call to useMediaQuery creates a separate matchMedia listener. Fix: This is fine for a handful of breakpoints. If you have dozens, consider a single listener with multiple breakpoints.
  • Invalid query stringmatchMedia does not throw for invalid queries; it returns a MediaQueryList that never matches. Fix: Validate queries during development.
  • iOS Safari quirks — Older versions of Safari use addListener/removeListener instead of addEventListener. Fix: Modern Safari supports the standard API. For legacy support, add a fallback.

Alternatives

PackageHook NameNotes
usehooks-tsuseMediaQuerySimilar API, well-tested
@uidotdev/usehooksuseMediaQueryMinimal implementation
ahooksuseResponsiveReturns breakpoint object
react-responsiveuseMediaQuerySupports server-side rendering with hints
Tailwind CSSResponsive classesCSS-only, no JS needed

FAQs

What does window.matchMedia return and how does the hook use it?
  • window.matchMedia(query) returns a MediaQueryList object.
  • The object has a matches boolean and fires a change event when the match state flips.
  • The hook reads matches on mount and subscribes to change for 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 useMediaQuery for 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)");