In a React component you subscribe to a media query with window.matchMedia('(max-width: 600px)') inside useEffect. What must that effect return, and what goes wrong in an app that mounts and unmounts that component many times if it doesn't?
answer
- the effect body and its return are a pair
- what has to happen at unmount
- one live listener per mounted component
- removeEventListener matches by reference
basics
~20 sThe effect must return a cleanup function that removes the exact listener it added. Without it, every mount leaves another live listener on the MediaQueryList, so subscriptions pile up, unmounted components keep reacting, and the retained closures leak memory.
solid answer
~50 sAn effect that subscribes to an outside system has to be symmetric: whatever it attaches, the function it returns detaches. For `matchMedia` that means calling `mql.addEventListener('change', onChange)` in the body and returning `() => mql.removeEventListener('change', onChange)`. React invokes that returned function when the component unmounts and before the effect runs again, so the number of live listeners stays at one per mounted component. If you skip it, nothing else cleans up for you — the `MediaQueryList` and `window` outlive your component, so each mount adds a listener that is never removed. In a page users navigate in and out of, you end up with dozens of handlers firing on every viewport change, each holding a closure over a dead component's state setter, which is both wasted work and a real memory leak. `removeEventListener` matches by reference, so the cleanup must pass the same function value you added, not a fresh arrow.
go deeper
Be able to write the subscribe-then-return-cleanup shape from memory, and say plainly that the cleanup removes the exact same handler reference the effect added.
Explain that the effect body and its cleanup are a matched pair, that removeEventListener compares handlers by reference, and why the MediaQueryList should be created inside the effect rather than during render.
Show how you would catch this in production: listener counts that only grow, work firing from screens the user already left, detached closures in a heap snapshot. Talk about auditing every subscription site for its release call.
Own the convention rather than the instance. Push subscriptions to outside systems into a small set of reviewed hooks whose API makes the unsubscribe unavoidable, instead of ad-hoc addEventListener calls spread across feature code.
## The pattern effects were designed for React state is the only source of truth React knows about. A media query, a WebSocket, a `window` event, a browser permission — these live outside React and change on their own schedule. The way you connect one to a component is an effect: subscribe when the component appears, unsubscribe when it goes away. That subscribe/unsubscribe pair is the whole shape, and the return value of the effect is how you express the second half. ```js useEffect(() => { const mql = window.matchMedia('(max-width: 600px)'); const onChange = (event) => setIsNarrow(event.matches); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange); }, []); ``` Read it as a matched pair. Everything the body acquires, the returned function releases. If you find yourself unable to write the second half, that is a signal the first half is doing something an effect should not be doing. ## Why nothing cleans up for you The intuition that trips people up is "the component is gone, so its listeners are gone." They are not. `addEventListener` stores a reference on the event target, and the event target here is a `MediaQueryList` derived from `window`, which lives for the lifetime of the page. That reference keeps the handler alive, the handler keeps its closure alive, and that closure holds the state setter and anything else it captured from the render it was created in. The component instance is unreachable from React's perspective, but it is very reachable from the browser's. The consequence compounds. Mount and unmount the same screen twenty times during a session and the media query now has twenty handlers. Every viewport change runs all twenty. Nineteen of them are calling setters belonging to components that no longer exist — harmless in the sense that React ignores updates to unmounted components, but not harmless in the sense that the work happens and the memory is retained. ## Matching by reference `removeEventListener(type, handler)` removes a listener only if the type, the handler reference, and the capture flag all match what was passed to `addEventListener`. This is the single most common way a cleanup silently does nothing: ```js // Broken: a brand-new arrow is created here, so nothing matches. return () => mql.removeEventListener('change', (e) => setIsNarrow(e.matches)); ``` Declare the handler once inside the effect body, use that same binding in both calls, and the problem disappears. Declaring it inside the effect rather than in the component body is deliberate: the handler is part of the subscription, created and discarded with it. ## Where the subscription object comes from Call `window.matchMedia(query)` inside the effect, not in the component body. A call in the component body runs on every render and creates a new `MediaQueryList` each time, and the one your cleanup captures may not be the one your body subscribed to. Creating the outside-world object inside the effect keeps its lifetime tied to the subscription's lifetime, which is exactly the property you want. ## Reading the initial value A subscription only tells you about *changes*. To render something on the first paint you also need the current value, which is why the state is usually initialized from `mql.matches`. Use a lazy initializer (`useState(() => window.matchMedia(query).matches)`) so the browser API is not called on every render. ## How the leak shows up In practice you notice it as: handlers running more times than there are components, warnings or logs firing from screens the user left, DevTools showing a listener count on `window` that only ever grows, and heap snapshots retaining detached component closures. All of these trace back to one missing `return`. ## The rule to carry away Any time an effect calls something named `add…`, `on…`, `subscribe`, `connect`, `observe`, or `start`, the returned cleanup calls the matching `remove…`, `off…`, `unsubscribe`, `disconnect`, `unobserve`, or `stop` — on the same object, with the same handler reference. If a code reviewer can see the acquire and not the release in the same effect, the effect is wrong.
- Why declare the handler inside the effect rather than in the component body?Because the handler belongs to the subscription. Declared inside, the same binding is used by both `addEventListener` and the cleanup, so the removal always matches. Declared in the component body it is recreated each render, and the value your cleanup closes over can differ from the one that was actually attached.
- Does React warn you if you forget the cleanup?No. React never inspects what an effect subscribed to; it only calls whatever function you return. A missing cleanup is invisible to React and shows up as growing listener counts, repeated work after navigation, or retained memory in a heap snapshot. The lint plugin cannot catch it either.
- You also need the current match value on first paint. Where does that come from?From reading the source directly, not from the subscription — a change listener only reports future changes. Initialize state lazily with `useState(() => window.matchMedia(query).matches)` so the browser API is consulted once rather than on every render, then let the listener keep it up to date.
saying these in an interview costs you the question
- Says React removes DOM listeners automatically on unmount
- Returns the handler itself instead of a cleanup function
- Calls removeEventListener with a freshly written inline arrow
- Thinks an empty dependency array alone prevents duplicate subscriptions
- Assumes leaked listeners are harmless because window always exists