Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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:
online/offline events without specifying an element (defaults to window)mousemove via a ref, with typed MouseEventkeydown with typed KeyboardEvent{ passive: true } for performancehandlerRef.current(event), so it always executes the freshest version of the handler. This eliminates stale closures without re-subscribing the listener.window, WindowEventMap types the event. For element refs, HTMLElementEventMap is used.window (default when element is undefined), document (when passed directly), or any element via React.RefObject.element is undefined and window is not available (SSR), the effect returns early without attaching anything.options parameter accepts the same values as native addEventListener (boolean for capture, or an options object with capture, passive, once).| 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.
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);
});window, document, and HTMLElement targets.window, the handler receives the correct event subtype (e.g., KeyboardEvent for "keydown", MouseEvent for "click").T extends HTMLElement can be narrowed: useRef<HTMLInputElement>(null).Event (the base type) to satisfy all overloads.{ passive: true } to avoid blocking the main thread. Fix: Pass the option explicitly when listening to scroll, touchstart, or touchmove.{ passive: true } inline), the effect re-subscribes each time. Fix: Memoize the options object or define it outside the component.{ capture: true } or true as the options parameter for capture-phase listening.addEventListener without cleanup, listeners accumulate. Fix: Always use this hook or manually pair addEventListener with removeEventListener in useEffect.event.target to identify the source element (event delegation pattern).| 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 |
handlerRef.current) ensures the listener always calls the latest version of the handler without re-subscribing.window as the event target.typeof window === "undefined" and returns early without attaching anything.Pass a React.RefObject as the third argument:
const ref = useRef<HTMLDivElement>(null);
useEventListener("click", handleClick, ref);
return <div ref={ref}>Click me</div>;Pass document directly as the third argument:
useEventListener("keydown", (e) => {
console.log(e.key);
}, document);options dependency in the effect.useMemo or define it as a constant outside the component.if (!element.current) return and skips attaching the listener.scroll, touchstart, and touchmove listeners to avoid blocking the main thread.preventDefault(), enabling smoother scrolling.eventName works with any event name, including custom events.CustomEvent to access the detail property.event.target to identify which child element triggered the event.WindowEventMap, DocumentEventMap, and HTMLElementEventMap.window for "keydown", the handler is typed as (event: KeyboardEvent) => void automatically.Specify the element type when creating the ref:
const inputRef = useRef<HTMLInputElement>(null);
useEventListener("focus", (e) => {
// e is typed as FocusEvent
}, inputRef);useEffect cleanup function calls removeEventListener, which runs when the component unmounts or when dependencies change.Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥