React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

event-listenerwindowdocumentrefcleanupcustom-hook

useEventListener — Declaratively add event listeners with auto-cleanup

Recipe

import { useEffect, useRef } from "react";
 
/**
 * useEventListener
 * Adds an event listener to window, document, or any element ref.
 * Uses the latest callback ref pattern to avoid re-subscribing on
 * callback changes. Cleans up automatically on unmount.
 */
 
// Overload: window events
function useEventListener<K extends keyof WindowEventMap>(
  eventName: K,
  handler: (event: WindowEventMap[K]) => void,
  element?: undefined,
  options?: boolean | AddEventListenerOptions
): void;
 
// Overload: document events
function useEventListener<K extends keyof DocumentEventMap>(
  eventName: K,
  handler: (event: DocumentEventMap[K]) => void,
  element: Document,
  options?: boolean | AddEventListenerOptions
): void;
 
// Overload: HTML element events
function useEventListener<
  K extends keyof HTMLElementEventMap,
  T extends HTMLElement = HTMLDivElement
>(
  eventName: K,
  handler: (event: HTMLElementEventMap[K]) => void,
  element: React.RefObject<T | null>,
  options?: boolean | AddEventListenerOptions
): void;
 
// Implementation
function useEventListener(
  eventName: string,
  handler: (event: Event) => void,
  element?: Document | React.RefObject<HTMLElement | null>,
  options?: boolean | AddEventListenerOptions
): void {
  const handlerRef = useRef(handler);
 
  useEffect(() => {
    handlerRef.current = handler;
  }, [handler]);
 
  useEffect(() => {
    // Determine the target element
    let targetElement: EventTarget;
 
    if (element === undefined) {
      // Default to window
      if (typeof window === "undefined") return;
      targetElement = window;
    } else if (element instanceof Document) {
      targetElement = element;
    } else {
      // It is a ref
      if (!element.current) return;
      targetElement = element.current;
    }
 
    const listener = (event: Event) => handlerRef.current(event);
 
    targetElement.addEventListener(eventName, listener, options);
 
    return () => {
      targetElement.removeEventListener(eventName, listener, options);
    };
  }, [eventName, element, options]);
}

When to reach for this: You need to attach event listeners to window, document, or DOM elements and want automatic cleanup, fresh callbacks, and type-safe event types without writing addEventListener/removeEventListener boilerplate.

Working Example

"use client";
 
import { useState, useRef } from "react";
 
// Track online/offline status
function OnlineStatus() {
  const [isOnline, setIsOnline] = useState(true);
 
  useEventListener("online", () => setIsOnline(true));
  useEventListener("offline", () => setIsOnline(false));
 
  return (
    <div
      style={{
        padding: 8,
        background: isOnline ? "#dcfce7" : "#fee2e2",
        borderRadius: 4,
      }}
    >
      {isOnline ? "Online" : "Offline"}
    </div>
  );
}
 
// Track mouse position on an element
function MouseTracker() {
  const ref = useRef<HTMLDivElement>(null);
  const [position, setPosition] = useState({ x: 0, y: 0 });
 
  useEventListener(
    "mousemove",
    (event) => {
      const rect = ref.current?.getBoundingClientRect();
      if (rect) {
        setPosition({
          x: event.clientX - rect.left,
          y: event.clientY - rect.top,
        });
      }
    },
    ref
  );
 
  return (
    <div
      ref={ref}
      style={{
        width: 300,
        height: 200,
        background: "#f5f5f5",
        border: "1px solid #ccc",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        cursor: "crosshair",
      }}
    >
      x: {position.x}, y: {position.y}
    </div>
  );
}
 
// Keyboard events on document
function KeyLogger() {
  const [lastKey, setLastKey] = useState("");
 
  useEventListener(
    "keydown",
    (event) => {
      setLastKey(event.key);
    },
    document
  );
 
  return <p>Last key pressed: {lastKey || "none"}</p>;
}
 
// Scroll with passive option
function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0);
 
  useEventListener(
    "scroll",
    () => setScrollY(window.scrollY),
    undefined,
    { passive: true }
  );
 
  return (
    <div style={{ position: "fixed", top: 0, right: 0, padding: 8 }}>
      Scroll: {scrollY}px
    </div>
  );
}

What this demonstrates:

  • OnlineStatus: Window online/offline events without specifying an element (defaults to window)
  • MouseTracker: Element-scoped mousemove via a ref, with typed MouseEvent
  • KeyLogger: Document-level keydown with typed KeyboardEvent
  • ScrollTracker: Window scroll with { passive: true } for performance
  • All listeners clean up automatically on unmount

Deep Dive

How It Works

  • Latest callback ref: The handler is stored in a ref and updated every render. The actual listener calls handlerRef.current(event), so it always executes the freshest version of the handler. This eliminates stale closures without re-subscribing the listener.
  • Overloaded signatures: TypeScript function overloads provide correct event types based on the target. When listening on window, WindowEventMap types the event. For element refs, HTMLElementEventMap is used.
  • Element resolution: The implementation supports three targets: window (default when element is undefined), document (when passed directly), or any element via React.RefObject.
  • SSR guard: When element is undefined and window is not available (SSR), the effect returns early without attaching anything.
  • Options passthrough: The options parameter accepts the same values as native addEventListener (boolean for capture, or an options object with capture, passive, once).

Parameters & Return Values

ParameterTypeDefaultDescription
eventNamestringDOM event name (e.g., "click", "scroll", "keydown")
handler(event: E) => voidEvent handler (typed per target)
elementundefined, Document, or RefObjectwindowEvent target
optionsboolean or AddEventListenerOptionsNative listener options

This hook returns void. It is purely a side-effect hook.

Variations

With cleanup flag: Return a function to manually remove the listener before unmount:

function useEventListener(eventName, handler, element) {
  // ...same setup...
  const removeRef = useRef<(() => void) | null>(null);
 
  useEffect(() => {
    // ...same logic...
    removeRef.current = () => {
      targetElement.removeEventListener(eventName, listener, options);
    };
    return removeRef.current;
  }, [eventName, element, options]);
 
  return { remove: () => removeRef.current?.() };
}

Media query listener: Use with matchMedia for responsive events:

// This is essentially what useMediaQuery does internally
const mql = window.matchMedia("(max-width: 768px)");
useEventListener("change", (e) => setIsMobile(e.matches), { current: mql } as any);

Custom event support: The string-based eventName works with custom events too:

useEventListener("my-custom-event", (event) => {
  console.log((event as CustomEvent).detail);
});

TypeScript Notes

  • Three function overloads provide type-safe events for window, document, and HTMLElement targets.
  • When listening on window, the handler receives the correct event subtype (e.g., KeyboardEvent for "keydown", MouseEvent for "click").
  • For element refs, the generic T extends HTMLElement can be narrowed: useRef<HTMLInputElement>(null).
  • The implementation signature uses Event (the base type) to satisfy all overloads.

Gotchas

  • Passive event warning — Scroll and touch listeners on window/document should use { passive: true } to avoid blocking the main thread. Fix: Pass the option explicitly when listening to scroll, touchstart, or touchmove.
  • Ref not ready on first render — If the ref element has not mounted yet (conditional rendering), the listener is not attached. Fix: The effect re-runs when the component re-renders and the ref becomes available. Make sure the ref is assigned before the effect runs.
  • Options reference equality — If you pass a new options object on every render (e.g., { passive: true } inline), the effect re-subscribes each time. Fix: Memoize the options object or define it outside the component.
  • Capture phase — By default, listeners attach in the bubble phase. Fix: Pass { capture: true } or true as the options parameter for capture-phase listening.
  • Memory leaks — If you bypass this hook and use raw addEventListener without cleanup, listeners accumulate. Fix: Always use this hook or manually pair addEventListener with removeEventListener in useEffect.
  • Event delegation — For lists with many items, attaching a listener to each item is wasteful. Fix: Attach a single listener to the parent and use event.target to identify the source element (event delegation pattern).

Alternatives

PackageHook NameNotes
usehooks-tsuseEventListenerSimilar overloaded API
@uidotdev/usehooksuseEventListenerMinimal, window-only
ahooksuseEventListenerSupports any event target
react-useuseEventSlightly different API
@react-aria/interactionsusePress, useHoverHigh-level interaction hooks

FAQs

Why does useEventListener store the handler in a ref instead of passing it directly to addEventListener?
  • The ref pattern (handlerRef.current) ensures the listener always calls the latest version of the handler without re-subscribing.
  • Without it, changing the handler would require removing and re-adding the event listener on every render.
What happens if no element argument is passed to useEventListener?
  • The hook defaults to window as the event target.
  • On the server (SSR), it checks typeof window === "undefined" and returns early without attaching anything.
How do you listen to events on a specific DOM element?

Pass a React.RefObject as the third argument:

const ref = useRef<HTMLDivElement>(null);
useEventListener("click", handleClick, ref);
return <div ref={ref}>Click me</div>;
How do you listen to document-level events like keydown?

Pass document directly as the third argument:

useEventListener("keydown", (e) => {
  console.log(e.key);
}, document);
Gotcha: Why does passing an inline options object like { passive: true } cause the listener to re-subscribe on every render?
  • A new object reference is created on each render, which changes the options dependency in the effect.
  • Fix: memoize the options object with useMemo or define it as a constant outside the component.
Gotcha: What happens if the ref element has not mounted yet when the effect runs?
  • The hook checks if (!element.current) return and skips attaching the listener.
  • When the component re-renders and the ref becomes available, the effect runs again and attaches the listener.
When should you use the { passive: true } option?
  • Use it for scroll, touchstart, and touchmove listeners to avoid blocking the main thread.
  • Passive listeners tell the browser the handler will not call preventDefault(), enabling smoother scrolling.
Can useEventListener handle custom events?
  • Yes. The string-based eventName works with any event name, including custom events.
  • Cast the event to CustomEvent to access the detail property.
Why should you prefer event delegation over attaching a listener to every list item?
  • Attaching listeners to many elements is wasteful and increases memory usage.
  • Instead, attach one listener to the parent and use event.target to identify which child element triggered the event.
How do TypeScript overloads provide type-safe event handling in this hook?
  • Three overloaded signatures map target types to event maps: WindowEventMap, DocumentEventMap, and HTMLElementEventMap.
  • When you listen on window for "keydown", the handler is typed as (event: KeyboardEvent) => void automatically.
How would you narrow the generic type for an element ref in TypeScript?

Specify the element type when creating the ref:

const inputRef = useRef<HTMLInputElement>(null);
useEventListener("focus", (e) => {
  // e is typed as FocusEvent
}, inputRef);
Does this hook clean up the listener automatically on unmount?
  • Yes. The useEffect cleanup function calls removeEventListener, which runs when the component unmounts or when dependencies change.