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/offlineevents without specifying an element (defaults to window) - MouseTracker: Element-scoped
mousemovevia a ref, with typedMouseEvent - KeyLogger: Document-level
keydownwith typedKeyboardEvent - 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,WindowEventMaptypes the event. For element refs,HTMLElementEventMapis used. - Element resolution: The implementation supports three targets:
window(default whenelementis undefined),document(when passed directly), or any element viaReact.RefObject. - SSR guard: When
elementis undefined andwindowis not available (SSR), the effect returns early without attaching anything. - Options passthrough: The
optionsparameter accepts the same values as nativeaddEventListener(boolean for capture, or an options object withcapture,passive,once).
Parameters & Return Values
| Parameter | Type | Default | Description |
|---|---|---|---|
eventName | string | — | DOM event name (e.g., "click", "scroll", "keydown") |
handler | (event: E) => void | — | Event handler (typed per target) |
element | undefined, Document, or RefObject | window | Event target |
options | boolean or AddEventListenerOptions | — | Native 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, andHTMLElementtargets. - When listening on
window, the handler receives the correct event subtype (e.g.,KeyboardEventfor"keydown",MouseEventfor"click"). - For element refs, the generic
T extends HTMLElementcan 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 }ortrueas the options parameter for capture-phase listening. - Memory leaks — If you bypass this hook and use raw
addEventListenerwithout cleanup, listeners accumulate. Fix: Always use this hook or manually pairaddEventListenerwithremoveEventListenerinuseEffect. - 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.targetto identify the source element (event delegation pattern).
Alternatives
| Package | Hook Name | Notes |
|---|---|---|
usehooks-ts | useEventListener | Similar overloaded API |
@uidotdev/usehooks | useEventListener | Minimal, window-only |
ahooks | useEventListener | Supports any event target |
react-use | useEvent | Slightly different API |
@react-aria/interactions | usePress, useHover | High-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
windowas 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
optionsdependency in the effect. - Fix: memoize the options object with
useMemoor 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) returnand 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, andtouchmovelisteners 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
eventNameworks with any event name, including custom events. - Cast the event to
CustomEventto access thedetailproperty.
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.targetto 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, andHTMLElementEventMap. - When you listen on
windowfor"keydown", the handler is typed as(event: KeyboardEvent) => voidautomatically.
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
useEffectcleanup function callsremoveEventListener, which runs when the component unmounts or when dependencies change.
Related
- useClickOutside — built on event listeners
- useKeyboardShortcut — keyboard event specialization
- useWindowSize — resize event listener
- useScrollToTop — scroll event listener
- useIntersectionObserver — observer-based alternative to scroll listeners