Build a hook that tells a component whether a CSS media query currently matches — and keeps that answer up to date. The browser exposes window.matchMedia, which evaluates a query like (min-width: 768px) and returns a MediaQueryList whose .matches is a boolean. The catch: that boolean is a snapshot. When the user resizes the window, rotates the device, or flips their system to dark mode, the value can change — but only code that subscribes to the list's change event finds out. useMediaQuery(query) reads the current match and re-renders the component whenever the match flips.
function useMediaQuery(query: string): boolean;
The query is any valid CSS media query string. The hook returns true when it matches right now and false otherwise, updating on every change.
function Layout() {
const isWide = useMediaQuery('(min-width: 768px)');
// Re-renders true→false→true as the window crosses 768px.
return <nav>{isWide ? <DesktopNav /> : <MobileNav />}</nav>;
}
function Theme() {
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
// Flips when the user changes their OS theme, with no resize involved.
return <div className={prefersDark ? 'dark' : 'light'}>...</div>;
}
matchMedia(query).matches, not false-by-default that corrects itself a tick later.change event, the hook must re-render with the new boolean. A version that reads .matches once and stops is wrong.query changes. If the component re-renders with a different query string, drop the old subscription and subscribe to the new list.change listener on unmount and before re-subscribing, so a stale list can't push updates into a gone or re-queried component.window.matchMedia exists. Don't worry about SSR or unsupported browsers here — that's noted in Going further.You'll read the current match from window.matchMedia(query) into state, then subscribe to that list's change event inside an effect so the state — and the component — updates every time the query starts or stops matching.
You want a component to know whether the screen is wide, or whether the user prefers dark mode, and to react when that changes. The browser gives you window.matchMedia('(min-width: 768px)'), which returns a MediaQueryList with a .matches boolean. Reading it is easy. The hard part is that .matches is a value frozen at the moment you read it — when the user resizes the window past 768px, your stored boolean does not magically flip. The MediaQueryList fires a change event at every crossing, and only code that listens for it learns the new value. So the job is two halves: read the current value, and stay subscribed to the changes.
A media query has a value that changes over time as the viewport (or the system) changes. Picture the boolean as a line that's false while the screen is narrow, snaps to true when it crosses the breakpoint, and snaps back when it narrows again. Each snap is a change event. Our hook has to track that line, not just sample it once.
We model "read once, then stay current" with the standard pair: useState holds the latest boolean, and a useEffect owns the subscription — subscribe in the effect body, unsubscribe in its cleanup. Tie the effect to [query] so it re-subscribes only when the query string changes.
The obvious version reads the match and stores it:
const { useState } = require('react');
function useMediaQuery(query) {
// Read the current match once...
const [matches] = useState(() => window.matchMedia(query).matches);
return matches;
}
This returns the correct value on the first render — but it never changes again. useState's initializer runs a single time, so matches is captured at mount and then frozen. Resize the window past the breakpoint and the MediaQueryList dutifully fires a change event, but nobody is listening, so the state never updates and the component never re-renders. The missing piece isn't where you read matchMedia — it's that you have to subscribe to the change event.
const { useState, useEffect } = require('react');
function useMediaQuery(query) {
// Seed state with the current match so the FIRST render is already correct,
// not false-by-default. The function form runs the read only once, at mount.
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mql = window.matchMedia(query);
// Re-render whenever the list crosses the breakpoint. The event carries the
// new value on e.matches, so we never have to re-read the list ourselves.
const onChange = (e) => setMatches(e.matches);
// The value can change between the initial read above and this effect
// running (or when `query` changes), so sync once more before subscribing.
setMatches(mql.matches);
mql.addEventListener('change', onChange);
// Cleanup: drop the listener on unmount AND before re-subscribing to a new
// query, so a stale list can't keep pushing updates.
return () => mql.removeEventListener('change', onChange);
}, [query]);
return matches;
}
module.exports = { useMediaQuery };
The shift from the naive version is the effect. useState still gives the right answer on the first render, but now a useEffect keyed on [query] subscribes to the list's change event and calls setMatches on every crossing — which re-renders the component with the fresh boolean. The cleanup removes that listener, so unmounting tears the subscription down and changing query swaps the old list for a new one cleanly. The extra setMatches(mql.matches) inside the effect closes a small gap: if the match flipped between the initial render and the effect committing, this catches it.
Take useMediaQuery('(min-width: 768px)') in a window that starts narrow, then gets dragged wide, then narrow again:
useState's initializer runs window.matchMedia('(min-width: 768px)').matches — false. The hook returns false. The component renders the mobile layout.matchMedia(query) to get the list, runs setMatches(mql.matches) (still false, no re-render since the value is unchanged), and subscribes: mql.addEventListener('change', onChange).change event with e.matches === true. onChange runs setMatches(true). React re-renders; the hook now returns true and the component swaps to the desktop layout.change fires, e.matches === false, setMatches(false) re-renders, and the hook returns false again.mql.removeEventListener('change', onChange). No further events reach the gone component.The value tracked the viewport at every step because the subscription stayed live for the component's whole lifetime.
.matches once and stopping. useState(() => matchMedia(query).matches) with no effect freezes the value at mount — resize all you want, it never updates. Fix: subscribe to the list's change event in a useEffect and call setMatches on each change.return () => removeEventListener(...), every query change stacks another listener on a new list while the old ones keep firing setMatches into a stale closure — a leak. Fix: always remove the listener in the effect's cleanup.query out of the deps. An empty [] dependency array means a component that switches from (min-width: 768px) to (min-width: 1200px) keeps listening to the old list and reports the wrong breakpoint. Fix: depend on [query] so the effect re-subscribes when the query changes.false. Starting at false and only correcting inside the effect causes a one-frame flash of the wrong layout on mount. Fix: seed useState with matchMedia(query).matches so the first render is already right.window, so window.matchMedia throws. Guard the initial read (return a sensible default like false when typeof window === 'undefined') and read the real value in the effect, which only runs in the browser.MediaQueryList.addListener/removeListener instead of addEventListener('change', ...). A robust hook feature-detects and falls back to the legacy API.useSyncExternalStore. React 18+ ships useSyncExternalStore, purpose-built for subscribing to external sources like a MediaQueryList. Re-expressing the hook with it removes the manual useState/useEffect dance and is tear-safe under concurrent rendering.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a hook that tells a component whether a CSS media query currently matches — and keeps that answer up to date. The browser exposes window.matchMedia, which evaluates a query like (min-width: 768px) and returns a MediaQueryList whose .matches is a boolean. The catch: that boolean is a snapshot. When the user resizes the window, rotates the device, or flips their system to dark mode, the value can change — but only code that subscribes to the list's change event finds out. useMediaQuery(query) reads the current match and re-renders the component whenever the match flips.
function useMediaQuery(query: string): boolean;
The query is any valid CSS media query string. The hook returns true when it matches right now and false otherwise, updating on every change.
function Layout() {
const isWide = useMediaQuery('(min-width: 768px)');
// Re-renders true→false→true as the window crosses 768px.
return <nav>{isWide ? <DesktopNav /> : <MobileNav />}</nav>;
}
function Theme() {
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
// Flips when the user changes their OS theme, with no resize involved.
return <div className={prefersDark ? 'dark' : 'light'}>...</div>;
}
matchMedia(query).matches, not false-by-default that corrects itself a tick later.change event, the hook must re-render with the new boolean. A version that reads .matches once and stops is wrong.query changes. If the component re-renders with a different query string, drop the old subscription and subscribe to the new list.change listener on unmount and before re-subscribing, so a stale list can't push updates into a gone or re-queried component.window.matchMedia exists. Don't worry about SSR or unsupported browsers here — that's noted in Going further.You'll read the current match from window.matchMedia(query) into state, then subscribe to that list's change event inside an effect so the state — and the component — updates every time the query starts or stops matching.
You want a component to know whether the screen is wide, or whether the user prefers dark mode, and to react when that changes. The browser gives you window.matchMedia('(min-width: 768px)'), which returns a MediaQueryList with a .matches boolean. Reading it is easy. The hard part is that .matches is a value frozen at the moment you read it — when the user resizes the window past 768px, your stored boolean does not magically flip. The MediaQueryList fires a change event at every crossing, and only code that listens for it learns the new value. So the job is two halves: read the current value, and stay subscribed to the changes.
A media query has a value that changes over time as the viewport (or the system) changes. Picture the boolean as a line that's false while the screen is narrow, snaps to true when it crosses the breakpoint, and snaps back when it narrows again. Each snap is a change event. Our hook has to track that line, not just sample it once.
We model "read once, then stay current" with the standard pair: useState holds the latest boolean, and a useEffect owns the subscription — subscribe in the effect body, unsubscribe in its cleanup. Tie the effect to [query] so it re-subscribes only when the query string changes.
The obvious version reads the match and stores it:
const { useState } = require('react');
function useMediaQuery(query) {
// Read the current match once...
const [matches] = useState(() => window.matchMedia(query).matches);
return matches;
}
This returns the correct value on the first render — but it never changes again. useState's initializer runs a single time, so matches is captured at mount and then frozen. Resize the window past the breakpoint and the MediaQueryList dutifully fires a change event, but nobody is listening, so the state never updates and the component never re-renders. The missing piece isn't where you read matchMedia — it's that you have to subscribe to the change event.
const { useState, useEffect } = require('react');
function useMediaQuery(query) {
// Seed state with the current match so the FIRST render is already correct,
// not false-by-default. The function form runs the read only once, at mount.
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mql = window.matchMedia(query);
// Re-render whenever the list crosses the breakpoint. The event carries the
// new value on e.matches, so we never have to re-read the list ourselves.
const onChange = (e) => setMatches(e.matches);
// The value can change between the initial read above and this effect
// running (or when `query` changes), so sync once more before subscribing.
setMatches(mql.matches);
mql.addEventListener('change', onChange);
// Cleanup: drop the listener on unmount AND before re-subscribing to a new
// query, so a stale list can't keep pushing updates.
return () => mql.removeEventListener('change', onChange);
}, [query]);
return matches;
}
module.exports = { useMediaQuery };
The shift from the naive version is the effect. useState still gives the right answer on the first render, but now a useEffect keyed on [query] subscribes to the list's change event and calls setMatches on every crossing — which re-renders the component with the fresh boolean. The cleanup removes that listener, so unmounting tears the subscription down and changing query swaps the old list for a new one cleanly. The extra setMatches(mql.matches) inside the effect closes a small gap: if the match flipped between the initial render and the effect committing, this catches it.
Take useMediaQuery('(min-width: 768px)') in a window that starts narrow, then gets dragged wide, then narrow again:
useState's initializer runs window.matchMedia('(min-width: 768px)').matches — false. The hook returns false. The component renders the mobile layout.matchMedia(query) to get the list, runs setMatches(mql.matches) (still false, no re-render since the value is unchanged), and subscribes: mql.addEventListener('change', onChange).change event with e.matches === true. onChange runs setMatches(true). React re-renders; the hook now returns true and the component swaps to the desktop layout.change fires, e.matches === false, setMatches(false) re-renders, and the hook returns false again.mql.removeEventListener('change', onChange). No further events reach the gone component.The value tracked the viewport at every step because the subscription stayed live for the component's whole lifetime.
.matches once and stopping. useState(() => matchMedia(query).matches) with no effect freezes the value at mount — resize all you want, it never updates. Fix: subscribe to the list's change event in a useEffect and call setMatches on each change.return () => removeEventListener(...), every query change stacks another listener on a new list while the old ones keep firing setMatches into a stale closure — a leak. Fix: always remove the listener in the effect's cleanup.query out of the deps. An empty [] dependency array means a component that switches from (min-width: 768px) to (min-width: 1200px) keeps listening to the old list and reports the wrong breakpoint. Fix: depend on [query] so the effect re-subscribes when the query changes.false. Starting at false and only correcting inside the effect causes a one-frame flash of the wrong layout on mount. Fix: seed useState with matchMedia(query).matches so the first render is already right.window, so window.matchMedia throws. Guard the initial read (return a sensible default like false when typeof window === 'undefined') and read the real value in the effect, which only runs in the browser.MediaQueryList.addListener/removeListener instead of addEventListener('change', ...). A robust hook feature-detects and falls back to the legacy API.useSyncExternalStore. React 18+ ships useSyncExternalStore, purpose-built for subscribing to external sources like a MediaQueryList. Re-expressing the hook with it removes the manual useState/useEffect dance and is tear-safe under concurrent rendering.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.