ESC

Type to search the knowledge base.

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.

intermediate6 min read
  • 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 target or type (or capture) changes. Do not rebind because the parent passed a new function identity.
  • handlerRef.current = handler in an effect (or during render). The listener wrapper always calls the ref.
  • removeEventListener needs the same function reference and the same capture flag you added with.
  • target.current on a ref does not trigger a re-render. If the node attaches late, store the node in state or pass window / document.
  • Cleanup is the leak fix. Rules of Hooks: no conditional useEventListener; pass enabled or 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 window in capture vs bubble is two different slots.
  • passive. Required for touch/wheel on some browsers if you will not preventDefault. Changing passive also 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 removeEventListener in case unmount happens first. A wrapper plus once: true is easy to get wrong; prefer handling once in your hook (enabled flip) if you need it.
  • signal. addEventListener(type, fn, { signal }) plus AbortController in cleanup is an alternative to removeEventListener. 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 an AbortSignal. If the target is a ref that attaches late, I use a callback ref or state, because mutating ref.current does not re-run effects. On the server I pass a null target so I never touch window during render.

Further reading

Related guides