ESC

Type to search the knowledge base.

useMediaQuery Hook

Subscribe to matchMedia, return a boolean, and avoid hydration mismatch by deferring the first client read or accepting a default.

intermediate5 min read
  • react
  • hooks
  • css
  • interview

CSS already responds to viewport and preferences. You reach for useMediaQuery when JavaScript has to branch: swap a dialog for a full-page sheet, skip a hover-only menu, or honor prefers-reduced-motion before starting an animation.

window.matchMedia(query) returns a MediaQueryList. .matches is the boolean. Subscribe to change, remove the listener on cleanup. The interview is the first paint: if the server prints false and the client’s first render reads true, you get a hydration mismatch.

Do not parse navigator.userAgent to guess “mobile.” That is UA sniffing. The query string is the feature.

  • matchMedia(query).matches is the snapshot. Subscribe with addEventListener('change', ...) and remove it in cleanup.
  • Old engines used addListener / removeListener. Feature-detect the modern pair first.
  • SSR has no window. Returning defaultMatches on the server and on the client’s first render keeps HTML aligned; then an effect (or useSyncExternalStore) updates.
  • useSyncExternalStore is the concurrent-safe subscribe API. useState + useEffect is what most interview whiteboards still expect.
  • Call the hook unconditionally. Pass a dummy query or ignore the boolean when the component does not care. Rules of Hooks.

matchMedia, addEventListener change, remove on cleanup

import { useEffect, useState } from 'react';

function useMediaQuery(query, defaultMatches = false) {
  const [matches, setMatches] = useState(defaultMatches);

  useEffect(() => {
    const mql = window.matchMedia(query);
    setMatches(mql.matches);

    function onChange(event) {
      setMatches(event.matches);
    }

    if (typeof mql.addEventListener === 'function') {
      mql.addEventListener('change', onChange);
      return () => mql.removeEventListener('change', onChange);
    }

    mql.addListener(onChange);
    return () => mql.removeListener(onChange);
  }, [query]);

  return matches;
}

Procedure:

  1. First render (server and hydrating client) returns defaultMatches. No window.
  2. After paint, the effect reads mql.matches and subscribes.
  3. Viewport or preference changes fire change. event.matches is a boolean; older listeners sometimes received the MediaQueryList itself, so event.matches ?? mql.matches is defensive.
  4. Cleanup removes the same function with the same API you used to add it.
  5. query change: cleanup, new matchMedia, new listener.
function Nav() {
  const compact = useMediaQuery('(max-width: 47.99rem)');
  return compact ? <MobileMenu /> : <DesktopMenu />;
}

function MotionAware() {
  const reduce = useMediaQuery('(prefers-reduced-motion: reduce)');
  return <Hero animate={!reduce} />;
}

Reduced motion belongs in CSS first. The hook is for JS-driven animation libraries that will not read the media query on their own.

Why the first client read is dangerous

This initializer looks convenient and breaks SSR:

const [matches, setMatches] = useState(
  () => typeof window !== 'undefined' && window.matchMedia(query).matches
);

Server: false (no window). Client first render: maybe true. React expected the client tree to match the server HTML. It does not.

Two honest options:

Strategy First paint Hydration After mount
Default, then effect (snippet above) defaultMatches on server and client Matches Snaps to real matches
useSyncExternalStore getServerSnapshot() Must equal first getSnapshot or you still mismatch Subscribe

If you need the real viewport on the first client render and you do not SSR that component, reading matchMedia in the initializer is fine. Say that constraint.

useSyncExternalStore version

import { useCallback, useSyncExternalStore } from 'react';

function useMediaQuery(query, serverMatches = false) {
  const subscribe = useCallback(
    (onStoreChange) => {
      const mql = window.matchMedia(query);
      mql.addEventListener('change', onStoreChange);
      return () => mql.removeEventListener('change', onStoreChange);
    },
    [query]
  );

  const getSnapshot = () => window.matchMedia(query).matches;
  const getServerSnapshot = () => serverMatches;

  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

This is the pattern in useSyncExternalStore Overview. getSnapshot must return a stable primitive (boolean is ideal). Returning a new object every call infinite-loops.

On the client, getSnapshot runs during render, so the first client render can already be true while getServerSnapshot was false. That is still a hydration mismatch if this component was in the server HTML. useSyncExternalStore does not magically make viewport state SSR-safe; it makes the subscription concurrent-safe and gives you an explicit server snapshot. For layout that must match, keep the default until after mount, or render the branch only after useEffect.

addEventListener vs addListener

MediaQueryList predates the EventTarget options object. Safari shipped addListener(callback) for years. Calling addEventListener where it does not exist throws, and then you have no subscription and no cleanup.

if (typeof mql.addEventListener === 'function') {
  mql.addEventListener('change', onChange);
} else {
  mql.addListener(onChange);
}

Do not mix: addEventListener paired with removeListener does not remove the listener.

addListener is deprecated. Still mention it; interviews like living APIs that have a corpse.

Footguns

Wrong query syntax. CSS range syntax @media (width >= 48rem) is not universally valid inside matchMedia in older engines. Prefer (min-width: 48rem) unless you know the baseline. See Media Queries Modern Approach.

Using this instead of CSS. If the only outcome is display: none, a media query in CSS is cheaper and does not wait for JS. Use the hook when you mount different components or talk to a non-CSS API.

query recreated every render. A template that changes identity (a new string such as "(min-width: " + n + "px)" when n changes) is a real query change. A new string with the same text is still Object.is equal. Unexpected query changes tear down the listener; that is correct. Do not format the string with extra spaces that oscillate.

Assuming window.matchMedia exists. Old environments and some test jsdom builds lack it. Guard or polyfill in tests.

Layout thrash. Subscribing to (min-width: 0px) or many queries that fire on every pixel of resize is possible. Prefer coarse breakpoints.

Interview out-loud answer

useMediaQuery calls matchMedia, returns matches, and listens for change. I remove the listener on cleanup, using addEventListener when it exists and addListener otherwise. For SSR I do not read window during the first render; I return a default and update in an effect so hydration matches. useSyncExternalStore is the better subscribe API, but the server snapshot still has to match the first client render if this markup is SSR’d. I would not sniff the user agent to pick a layout.

Further reading

Related guides