Skip to main content

useEventListener

Attaches a typed event listener to window, document, an element (or a ref to one), or any EventTarget, and removes it automatically on cleanup.

When to use it​

  • Reacting to resize, scroll or keydown on the window or document.
  • Listening on an element held by a ref without writing the add/remove-in-an-effect boilerplate.
  • Subscribing to custom events or other EventTargets such as MediaQueryList.

Demo​

Loading demo…

Signature​

type EventListenerTarget<T extends EventTarget> =
T | RefObject<T | null> | null;

function useEventListener<K extends keyof WindowEventMap>(
type: K,
handler: (event: WindowEventMap[K]) => void,
target?: EventListenerTarget<Window>,
options?: AddEventListenerOptions
): void;
// Equivalent overloads exist for Document, HTMLElement (or a ref to one),
// and any EventTarget with a custom event type:
function useEventListener<E extends Event = Event>(
type: string,
handler: (event: E) => void,
target: EventListenerTarget<EventTarget>,
options?: AddEventListenerOptions
): void;
ParameterDescription
typeEvent name. The handler's event type is inferred for window, document and elements.
handlerCalled with the event. The latest handler is always used.
targetwindow (default when omitted), document, an element, a ref, any EventTarget, or null.
optionsStandard addEventListener options: capture, passive, once, signal.

Returns: void

Notes​

  • No re-subscribing on handler change. Pass an inline function freely; the hook calls the newest one through useLatest.
  • null attaches nothing. Use it for a ref that is not mounted yet, or to conditionally disable the listener, as in the demo.
  • Refs are read when the effect runs. A ref attached in the same commit works; swapping ref.current afterwards does not re-subscribe.
  • Options. Changing capture, passive, once or signal re-subscribes. Inline option objects are fine. Use signal to detach from outside the component.
  • Custom events. Provide the event type as a generic, for example useEventListener<CustomEvent<number>>("ping", (e) => e.detail, bus).
  • SSR-safe. Nothing is attached on the server.
  • Works with React 18 and 19, including StrictMode.

Example​

// window
useEventListener("resize", () => setWidth(window.innerWidth));

// document
useEventListener("keydown", (e) => e.key === "Escape" && close(), document);

// element via ref
const ref = useRef<HTMLDivElement>(null);
useEventListener("scroll", () => update(), ref, { passive: true });

Source​

src/hooks/use-event-listener.ts
import type { RefObject } from "react";
import { useEffect } from "react";

import { useLatest } from "./use-latest";

/** A target accepted by {@link useEventListener}: the object itself or a ref to it. */
export type EventListenerTarget<T extends EventTarget> =
T | RefObject<T | null> | null;

/** Call signatures of {@link useEventListener}. */
export interface UseEventListener {
/** Listen on `window` (the default when no target is given). */
<K extends keyof WindowEventMap>(
type: K,
handler: (event: WindowEventMap[K]) => void,
target?: EventListenerTarget<Window>,
options?: AddEventListenerOptions
): void;
/** Listen on `document`. */
<K extends keyof DocumentEventMap>(
type: K,
handler: (event: DocumentEventMap[K]) => void,
target: EventListenerTarget<Document>,
options?: AddEventListenerOptions
): void;
/** Listen on an HTML element, or a ref to one. */
<K extends keyof HTMLElementEventMap, T extends HTMLElement = HTMLElement>(
type: K,
handler: (event: HTMLElementEventMap[K]) => void,
target: EventListenerTarget<T>,
options?: AddEventListenerOptions
): void;
/** Listen for any event on any `EventTarget` (custom events, `MediaQueryList`, ...). */
<E extends Event = Event>(
type: string,
handler: (event: E) => void,
target: EventListenerTarget<EventTarget>,
options?: AddEventListenerOptions
): void;
}

const isRef = <T extends EventTarget>(
target: T | RefObject<T | null>
): target is RefObject<T | null> => !("addEventListener" in target);

/**
* Attaches an event listener to `window`, `document`, an element, or any
* other `EventTarget`, and removes it automatically on cleanup.
*
* - **Target:** defaults to `window`. Pass `document`, an element, a ref, or
* any `EventTarget`. A `null` target (for example a ref that is not yet
* attached) attaches nothing.
* - **Handler:** always the latest one is called, so passing an inline
* function never causes re-subscription.
* - **Options:** standard `addEventListener` options (`capture`, `passive`,
* `once`, `signal`). Changing them re-subscribes; inline option objects are
* fine.
* - **SSR:** does nothing when there is no DOM.
*
* Refs are read when the effect runs, so a ref that is attached in the same
* commit works, but swapping `ref.current` later does not re-subscribe.
*
* @param type - The event name. The handler's event type is inferred from it for `window`, `document` and elements.
* @param handler - Called with the event whenever it fires.
* @param target - Where to listen. Defaults to `window`.
* @param options - Standard `addEventListener` options.
*
* @example
* ```tsx
* // window
* useEventListener("resize", () => setWidth(window.innerWidth));
*
* // document
* useEventListener("keydown", (e) => e.key === "Escape" && close(), document);
*
* // element via ref
* const ref = useRef<HTMLDivElement>(null);
* useEventListener("scroll", (e) => console.log(e.currentTarget), ref, {
* passive: true,
* });
* ```
*/
export const useEventListener = (<E extends Event>(
type: string,
handler: (event: E) => void,
target?: EventListenerTarget<EventTarget>,
options?: AddEventListenerOptions
): void => {
const handlerRef = useLatest(handler);
const { capture, passive, once, signal } = options ?? {};

useEffect(() => {
const resolved =
target === undefined
? typeof window === "undefined"
? null
: window
: target !== null && isRef(target)
? target.current
: target;

if (!resolved) return;

const listener = (event: Event) => handlerRef.current(event as E);
const listenerOptions = { capture, passive, once, signal };

resolved.addEventListener(type, listener, listenerOptions);
return () => resolved.removeEventListener(type, listener, listenerOptions);
}, [type, target, handlerRef, capture, passive, once, signal]);
}) as UseEventListener;