useMediaQuery Hook
Subscribe to matchMedia, return a boolean, and avoid hydration mismatch by deferring the first client read or accepting a default.
- 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).matchesis the snapshot. Subscribe withaddEventListener('change', ...)and remove it in cleanup.- Old engines used
addListener/removeListener. Feature-detect the modern pair first.- SSR has no
window. ReturningdefaultMatcheson the server and on the client’s first render keeps HTML aligned; then an effect (oruseSyncExternalStore) updates.useSyncExternalStoreis the concurrent-safe subscribe API.useState+useEffectis 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:
- First render (server and hydrating client) returns
defaultMatches. Nowindow. - After paint, the effect reads
mql.matchesand subscribes. - Viewport or preference changes fire
change.event.matchesis a boolean; older listeners sometimes received theMediaQueryListitself, soevent.matches ?? mql.matchesis defensive. - Cleanup removes the same function with the same API you used to add it.
querychange: cleanup, newmatchMedia, 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
useMediaQuerycallsmatchMedia, returnsmatches, and listens forchange. I remove the listener on cleanup, usingaddEventListenerwhen it exists andaddListenerotherwise. For SSR I do not readwindowduring the first render; I return a default and update in an effect so hydration matches.useSyncExternalStoreis 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
- MDN:
Window.matchMedia - MDN:
MediaQueryList - MDN:
prefers-reduced-motion - react.dev:
useSyncExternalStore
Related on this site
- useSyncExternalStore Overview
- Hydration Mismatches
- Rules of Hooks
- useEffect Fundamentals
- Custom Hooks Design
- Media Queries Modern Approach
- Reduced Motion Preferences
- Feature Detection vs UA Sniffing
- Arrays That Emit Change Events
Related guides
- useCopyToClipboard HookCopy text with navigator.clipboard, fallback execCommand, transient copied state, and permissions that fail in insecure contexts.
- useDebouncedValue HookDebounce a changing value in React without firing effects on every keystroke. Cleanup, leading vs trailing, and the stale timer that updates unmounted state.
- useEventListener HookAttach a window, document, or element listener with a stable handler ref so you do not rebind on every render. Capture option and target changes.
- useIdle HookDetect idle users: reset a timer on input events, fire idle after timeout, and decide whether mount starts the timer immediately.
- useInfiniteScroll HookLoad the next page when a sentinel enters the viewport. IntersectionObserver, hasMore, in-flight guards, and the footer that never intersects.