Build a useMediaQuery Hook for Responsive React UIs

Build a useMediaQuery Hook that tracks a CSS media query with matchMedia, updates on viewport changes, and drives responsive React layouts.

6 min read

A useMediaQuery Hook returns whether a CSS media query currently matches, and re-renders the component when that answer flips. It turns viewport breakpoints into ordinary React state that any component can read. It works for width, orientation, or any feature that CSS can test.

Read the query with matchMedia

window.matchMedia takes a query string and returns a MediaQueryList. Its matches property holds the current answer, and its change event fires only when the query starts or stops matching. This is more precise than listening for every resize event.

App.jsxApp.jsx
import { useEffect, useState } from "react";
function useMediaQuery(query) {
  const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
  useEffect(() => {
    const list = window.matchMedia(query);
    const onChange = (event) => setMatches(event.matches);
    list.addEventListener("change", onChange);
    return () => list.removeEventListener("change", onChange);
  }, [query]);
  return matches;
}

The lazy initializer reads the answer once during the first render, so the Hook never starts with a wrong value. The Effect subscribes to change and cleans up the listener when query changes or the component unmounts.

Strict Mode may run the Effect twice in development, and the cleanup makes that harmless. The change event carries the new matches value directly, so the listener never has to recompute it.

Use it for a responsive layout

Call the Hook with a query and branch on the returned boolean.

App.jsxApp.jsx
function Navigation() {
  const isNarrow = useMediaQuery("(width <= 600px)");
  return (
    <nav>
      {isNarrow ? <button>Menu</button> : <span>Full navigation links</span>}
    </nav>
  );
}

Below 600 pixels the nav shows a compact Menu button, and above it the full links. Resizing the window across the breakpoint swaps the two without a page reload. The returned boolean stays current without a resize handler, and the same Hook can drive any number of layout decisions in one component.

Mind the query syntax and the server

Media features must be wrapped in parentheses, so (width <= 600px) works while width <= 600px does not. The Hook reads window during render, so it targets client-rendered apps. For server rendering, use useSyncExternalStore with a getServerSnapshot function that returns a stable initial value.

Track several breakpoints

Call useMediaQuery once per breakpoint and name the results. A dashboard can compute isTablet and isDesktop side by side, then choose its layout from those booleans. Each call keeps its own subscription, so the listeners are cleaned up independently.

Prefer CSS when it can do the job

For styling changes, a plain CSS media query is simpler and cheaper than JavaScript state. Reach for useMediaQuery only when behavior must change, such as swapping markup or deciding what a component renders. If several components need the same breakpoint, wrap the shared logic once in the Hook instead of repeating matchMedia calls. JavaScript media queries exist for the cases CSS alone cannot express in markup.

See how to create a custom Hook for the extraction steps, and how to synchronize React with browser APIs for the subscription pattern behind it.

Rune AI

Rune AI

Key Insights

  • Read the initial matches value with a lazy useState initializer.
  • Subscribe to the MediaQueryList change event, not resize.
  • Remove the listener in the Effect cleanup.
  • Wrap media features in parentheses, like (width <= 600px).
  • Use useSyncExternalStore when server rendering matters.
RunePowered by Rune AI

Frequently Asked Questions

What does matchMedia return?

window.matchMedia returns a MediaQueryList with a matches property and a change event. matches tells you whether the query currently applies, and change fires when that flips.

Why subscribe to the change event instead of resize?

The change event fires exactly when a query starts or stops matching, so the Hook re-renders only when the breakpoint actually crosses.

Does this Hook work during server rendering?

The useState initializer reads window, so it targets client-rendered apps. For server rendering, use useSyncExternalStore with a getServerSnapshot function instead.

Conclusion

A useMediaQuery Hook wraps window.matchMedia so a component can re-render when a breakpoint flips. Read the initial value lazily, subscribe to the change event, and clean up the listener when the query changes or the component unmounts.