A custom React hook can work in a browser demo and still fail when the page is rendered on a server. Media queries make that mistake easy: reading window in a state initializer assumes the browser exists before React starts rendering. I want the hook's environment and cleanup rules to be part of its API.
1. Decide whether the component needs a hook
I split SwiftSearch into a 4.5 KB gzipped core and an 18 KB interface loaded on search intent. The SwiftSearch case study documents that boundary; it does not document the hook below as production code. The lesson I carry into React examples is to give each piece of behavior a clear reason to exist.
A media-query hook makes sense when behavior changes, such as whether an optional animation should run. It is usually unnecessary when the only requirement is a narrower grid or smaller margin. I would implement those differences in CSS, keeping the same useful content available before JavaScript starts.
Write down the hook's consumer before its implementation. What value does the component need, how can that value change, and who owns the event source? If the answer is a calculation from existing props, an ordinary function may be enough. Adding a subscription would create another moving part without solving a state problem.
2. Separate the server snapshot from browser state
React's useSyncExternalStore reference defines three responsibilities: subscribe to changes, read the current snapshot, and provide the server snapshot when rendering on the server. That last value must also match the initial hydration snapshot. It is not a claim that the server knows the visitor's viewport.
The following module targets React 18 or later and modern browsers. It returns false on the server, then reflects the browser query after hydration. Call it at the top level of a component or another hook; do not call it conditionally inside a click handler.
import { useCallback, useSyncExternalStore } from 'react';
const getServerSnapshot = () => false;
export function useMediaQuery(query) {
const subscribe = useCallback((notify) => {
const media = window.matchMedia(query);
media.addEventListener('change', notify);
return () => media.removeEventListener('change', notify);
}, [query]);
const getSnapshot = useCallback(
() => window.matchMedia(query).matches,
[query],
);
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}The snapshot is a boolean, so unchanged reads do not manufacture a new object identity. The subscription function stays stable while the query stays stable. When the query changes, its dependency changes and React can replace the old subscription with the new one.
Choose the fallback deliberately. If false means “hide the only navigation,” the component design needs correction. A safe initial value should preserve a usable page, and essential layout should not depend on discovering the viewport through JavaScript.
3. Test changes after the first render
MDN's matchMedia documentation describes the initial match value and the change event. A useful test exercises both. Mounting a component once and checking its first label does not show whether resizing or a new query works.
For this hook, I would test a narrow viewport, resize across the breakpoint, change the query prop, and unmount. I would also server-render it with no browser globals available. Those checks address the actual failure modes rather than asserting that a particular React function was called.
| Situation | Expected result | Failure to investigate |
|---|---|---|
| Server render | Consistent fallback without browser access | Reading window during rendering |
| First hydrated render | Compatible initial markup | Different server and client assumptions |
| Breakpoint crossed | Consumer reflects the new match | Missing change subscription |
| Query prop replaced | New query controls the result | Stale callback dependencies |
| Component unmounted | Old listener is removed | Cleanup bound to a different listener |
Keep these checks attached to a real consumer. For example, a reduced-motion preference should change the optional animation behavior while preserving the product information. The hook returning a correct boolean is only part of that outcome.
4. Give observers the same ownership discipline
An intersection observer introduces another input: the actual DOM node being observed. A ref object can stay the same while its current node changes. If an element appears only after loading, an effect that ran before the element existed needs a way to observe the new node.
I would make that node change explicit with a callback ref or a component boundary that mounts with the element. Then the effect can disconnect the previous observer and attach to the current element. React's effect lifecycle guidance explains why setup and cleanup must remain symmetrical as dependencies change.
Avoid bundling visibility, analytics and network loading into a hook named only useInView. A consumer may want one of those behaviors without the others. Return the visibility information, then make the decision to load or record an event at a boundary where its purpose is clear.
The embeddable widget guide covers the wider installation boundary. The search widget loading walkthrough deals with preserving typed input while an optional interface arrives. Neither problem is solved merely by introducing a reusable hook.
5. Review the contract before reusing it
Before copying a hook into another project, I would write down its assumptions: supported browser APIs, server fallback, input identity, listener lifetime and behavior after unmount. That short contract is more useful than calling it universally reusable.
For this example, start by integrating useMediaQuery into one optional behavior and running the test sequence above. Keep a CSS-only version in mind as the simpler alternative when the requirement is purely visual. For slow catalog interactions rather than subscriptions, continue with the React performance diagnosis guide.