useEventListener Hook
Attach a window, document, or element listener with a stable handler ref so you do not rebind on every render. Capture option and target changes.
- react
- hooks
- events
- interview
window.addEventListener('resize', handler) inside a component is easy to get almost right. The almost: you pass an inline handler, put it in the effect deps, and rebind on every render. Or you omit it, and the listener closes over stale state. Or you forget cleanup and leak across navigations (Memory Leaks in SPAs).
useEventListener is the subscription helper: keep the latest handler in a ref, and only call addEventListener / removeEventListener when the target, type, or capture flag changes. Same split as useInterval: stable subscription, live callback.
Targets are window, document, or an element (often via ref). SSR has no window. The hook still has to run.
- Rebind when
targetortype(orcapture) changes. Do not rebind because the parent passed a new function identity.handlerRef.current = handlerin an effect (or during render). The listener wrapper always calls the ref.removeEventListenerneeds the same function reference and the same capture flag you added with.target.currenton a ref does not trigger a re-render. If the node attaches late, store the node in state or passwindow/document.- Cleanup is the leak fix. Rules of Hooks: no conditional
useEventListener; passenabledor a null target.
Rebind only when the target or type changes
import { useEffect, useRef } from 'react';
function useEventListener(target, type, handler, options) {
const handlerRef = useRef(handler);
const capture = typeof options === 'boolean' ? options : options?.capture ?? false;
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
const node = resolveTarget(target);
if (!node || typeof node.addEventListener !== 'function') {
return undefined;
}
function listener(event) {
handlerRef.current(event);
}
node.addEventListener(type, listener, capture);
return () => {
node.removeEventListener(type, listener, capture);
};
}, [target, type, capture]);
}
function resolveTarget(target) {
if (target == null) return null;
if (typeof window === 'undefined') return null;
if (target === 'window' || target === window) return window;
if (target === 'document' || target === document) return document;
if (typeof target.addEventListener === 'function') return target;
if ('current' in target) return target.current;
return null;
}
function KeyHint() {
const [key, setKey] = useState('');
useEventListener(typeof window !== 'undefined' ? window : null, 'keydown', (e) => {
setKey(e.key);
});
return <p>Last key: {key || '(none)'}</p>;
}
Usage for an element:
function Box() {
const ref = useRef(null);
useEventListener(ref, 'pointerdown', (e) => {
console.log('hit', e.currentTarget);
});
return <div ref={ref} />;
}
On the first effect run in the same component, React has already assigned ref.current (refs flush before useLayoutEffect / useEffect). resolveTarget(ref) should see the div. If the node is conditional ({open && <div ref={ref} />}), the effect may run while current is null and will not re-run when the div appears, because ref (the object) never changed. Fix with state:
const [node, setNode] = useState(null);
useEventListener(node, 'click', onClick);
return <button type="button" ref={setNode} />;
Why the wrapper function exists
removeEventListener matches by function reference. If you add handler directly and the parent passes a new handler next render, you cannot remove the old one unless you kept it. The wrapper listener is allocated inside the subscribe effect, so it is stable for the life of that subscription. It always forwards to handlerRef.current.
| Change | Rebind listener? |
|---|---|
New handler function, same target/type |
No. Ref update only. |
'resize' → 'scroll' |
Yes |
window → document |
Yes |
capture: false → true |
Yes |
options object new identity, same capture |
No, if you only depend on capture |
Depending on a raw options object is the same trap as useFetch options: { capture: true } in JSX is a new object every render and would thrash. Pull out the boolean (and passive if you need it) as primitives.
Capture, passive, once, signal
Capture, bubble, once, passive:
- capture. Must match on remove. A scroll listener on
windowin capture vs bubble is two different slots. - passive. Required for touch/wheel on some browsers if you will not
preventDefault. Changingpassivealso requires rebind; include it in deps if you expose it. - once. The browser removes the native listener after one fire. Your cleanup still has to
removeEventListenerin case unmount happens first. A wrapper plusonce: trueis easy to get wrong; prefer handling once in your hook (enabledflip) if you need it. - signal.
addEventListener(type, fn, { signal })plusAbortControllerin cleanup is an alternative toremoveEventListener. Abort on effect cleanup. Do not mix both styles on the same wrapper without care.
useEffect(() => {
const node = resolveTarget(target);
if (!node) return undefined;
const controller = new AbortController();
node.addEventListener(type, (e) => handlerRef.current(e), {
capture,
signal: controller.signal,
});
return () => controller.abort();
}, [target, type, capture]);
That is valid modern DOM. Interviews still want you to know removeEventListener.
window vs document vs element
| Target | Typical events |
|---|---|
window |
resize, keydown (if you want global), online / offline |
document |
pointerdown for outside click, visibilitychange (actually document) |
| Element | scroll on an overflow panel, input on a node you do not control |
Do not attach 500 row-level listeners if one parent plus event delegation will do. This hook is for the cases that are not delegated: window-level, or a single widget root.
SSR: typeof window === 'undefined'. Passing window at the call site throws during import/render on the server if you evaluate window eagerly. Pass null on the server (typeof window !== 'undefined' ? window : null) or resolve inside the effect only:
useEventListener('window', 'resize', onResize);
// resolveTarget maps the string 'window' after mount
A string token ('window' | 'document') is a friendly API so the render path never touches window. Worth mentioning if they ask about Next.js.
Footguns
Forgetting capture on remove. The listener stays. You add a second one next time. Resize handlers multiply.
handler that calls setState on an unmounted tree. Cleanup removes the listener, so you should not get events after unmount. If handler starts async work, that work still needs its own abort.
Passive + preventDefault. The browser ignores preventDefault on a passive listener. Scroll jank notes belong here if the question is about touch.
Target is options accidentally. Keep the argument order (target, type, handler, options) consistent with addEventListener’s mental model, not (type, handler, target).
Conditional hook calls. if (open) useEventListener(...) breaks call order. Use target: open ? node : null or if (!node) return inside the effect.
Interview out-loud answer
I would not put the user’s handler in the subscribe effect’s dependency array. I copy it into a ref and add a stable wrapper with
addEventListener. The effect depends on the target, the event type, and capture, so I rebind only when the subscription actually changes. Cleanup removes the same wrapper with the same capture flag, or aborts anAbortSignal. If the target is a ref that attaches late, I use a callback ref or state, because mutatingref.currentdoes not re-run effects. On the server I pass a null target so I never touchwindowduring render.
Further reading
- MDN:
EventTarget.addEventListener - MDN:
EventTarget.removeEventListener - DOM spec: event listeners
- react.dev: Synchronizing with Effects
Related on this site
- Capture, Bubble, once, and passive
- Event Delegation
- Rules of Hooks
- useEffect Fundamentals
- Custom Hooks Design
- useRef for Values and DOM
- useOnClickOutside Hook
- Arrays That Emit Change Events
- Memory Leaks in SPAs
Related guides
- useIdle HookDetect idle users: reset a timer on input events, fire idle after timeout, and decide whether mount starts the timer immediately.
- 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.
- useInfiniteScroll HookLoad the next page when a sentinel enters the viewport. IntersectionObserver, hasMore, in-flight guards, and the footer that never intersects.
- useInterval HookA setInterval that sees the latest callback without restarting the timer when the callback identity changes. Dan Abramov’s ref pattern, named.