useEventListener — Agrega listeners de eventos declarativamente con limpieza automática
Receta
import { useEffect, useRef } from "react";
/**
* useEventListener
* Agrega un listener de evento a window, document o cualquier ref de elemento.
* Usa el patrón de ref de callback más reciente para evitar volver a suscribirse en
* cambios de callback. Se limpia automáticamente en desmontar.
*/
// Sobrecarga: eventos de window
function useEventListener<K extends keyof WindowEventMap>(
eventName: K,
handler: (event: WindowEventMap[K]) => void,
element?: undefined,
options?: boolean | AddEventListenerOptions
): void;
// Sobrecarga: eventos de document
function useEventListener<K extends keyof DocumentEventMap>(
eventName: K,
handler: (event: DocumentEventMap[K]) => void,
element: Document,
options?: boolean | AddEventListenerOptions
): void;
// Sobrecarga: eventos de elementos HTML
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;
// Implementación
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(() => {
// Determina el elemento objetivo
let targetElement: EventTarget;
if (element === undefined) {
// Por defecto es window
if (typeof window === "undefined") return;
targetElement = window;
} else if (element instanceof Document) {
targetElement = element;
} else {
// Es una 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]);
}Cuándo usarlo: Necesitas adjuntar listeners de eventos a window, document o elementos DOM y deseas limpieza automática, callbacks frescos y tipos de eventos type-safe sin escribir boilerplate de addEventListener/removeEventListener.
Ejemplo Funcional
"use client";
import { useState, useRef } from "react";
// Rastrear estado en línea/fuera de línea
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 ? "En línea" : "Fuera de línea"}
</div>
);
}
// Rastrear posición del ratón en un elemento
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>
);
}
// Eventos de teclado en document
function KeyLogger() {
const [lastKey, setLastKey] = useState("");
useEventListener(
"keydown",
(event) => {
setLastKey(event.key);
},
document
);
return <p>Última tecla presionada: {lastKey || "ninguna"}</p>;
}
// Desplazamiento con opción passive
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 }}>
Desplazamiento: {scrollY}px
</div>
);
}Lo que esto demuestra:
- OnlineStatus: Eventos
online/offlinede window sin especificar un elemento (por defecto es window) - MouseTracker:
mousemovede alcance de elemento vía una ref, conMouseEventtipado - KeyLogger:
keydowna nivel de document conKeyboardEventtipado - ScrollTracker: Desplazamiento de window con
{ passive: true }para rendimiento - Todos los listeners se limpian automáticamente en desmontar
Profundización
Cómo funciona
- Ref de callback más reciente: El handler se almacena en una ref y se actualiza en cada renderizado. El listener actual llama a
handlerRef.current(event), así que siempre ejecuta la versión más fresca del handler. Esto elimina cierre obsoleto sin volver a suscribir el listener. - Firmas sobrecargadas: Las sobrecargas de función de TypeScript proporcionan tipos de eventos correctos basados en el objetivo. Cuando escuchas en
window,WindowEventMaptipan el evento. Para refs de elementos, se utilizaHTMLElementEventMap. - Resolución de elementos: La implementación soporta tres objetivos:
window(por defecto cuandoelementes undefined),document(cuando se pasa directamente), o cualquier elemento víaReact.RefObject. - Guardia SSR: Cuando
elementes undefined ywindowno está disponible (SSR), el efecto retorna temprano sin adjuntar nada. - Paso de opciones: El parámetro
optionsacepta los mismos valores que eladdEventListenernativo (booleano para captura, u objeto de opciones concapture,passive,once).
Parámetros y Valores de Retorno
| Parámetro | Tipo | Por defecto | Descripción |
|---|---|---|---|
eventName | string | — | Nombre de evento DOM (p. ej., "click", "scroll", "keydown") |
handler | (event: E) => void | — | Handler de evento (tipado por objetivo) |
element | undefined, Document, o RefObject | window | Objetivo del evento |
options | boolean o AddEventListenerOptions | — | Opciones nativas de listener |
Este hook retorna void. Es puramente un hook de efecto secundario.
Variaciones
Con bandera de limpieza: Retorna una función para remover manualmente el listener antes de desmontar:
function useEventListener(eventName, handler, element) {
// ...misma configuración...
const removeRef = useRef<(() => void) | null>(null);
useEffect(() => {
// ...misma lógica...
removeRef.current = () => {
targetElement.removeEventListener(eventName, listener, options);
};
return removeRef.current;
}, [eventName, element, options]);
return { remove: () => removeRef.current?.() };
}Listener de media query: Usa con matchMedia para eventos responsivos:
// Esto es esencialmente lo que useMediaQuery hace internamente
const mql = window.matchMedia("(max-width: 768px)");
useEventListener("change", (e) => setIsMobile(e.matches), { current: mql } as any);Soporte de eventos personalizados: El eventName basado en string funciona con eventos personalizados también:
useEventListener("my-custom-event", (event) => {
console.log((event as CustomEvent).detail);
});Notas de TypeScript
- Tres sobrecargas de función proporcionan eventos type-safe para objetivos
window,documentyHTMLElement. - Cuando escuchas en
windowpara"keydown", el handler se tipea automáticamente como(event: KeyboardEvent) => void. - Para refs de elementos, el genérico
T extends HTMLElementpuede ser reducido:useRef<HTMLInputElement>(null). - La firma de implementación usa
Event(el tipo base) para satisfacer todas las sobrecargas.
Trampas
- Advertencia de evento pasivo — Los listeners de scroll y touch en window/document deben usar
{ passive: true }para evitar bloquear el thread principal. Solución: Pasa la opción explícitamente cuando escuches scroll, touchstart, o touchmove. - Ref no lista en el primer renderizado — Si el elemento ref aún no se ha montado (renderizado condicional), el listener no se adjunta. Solución: El efecto se vuelve a ejecutar cuando el componente se renderiza y la ref se vuelve disponible. Asegúrate de que la ref se asigne antes de que se ejecute el efecto.
- Igualdad de referencia de opciones — Si pasas un nuevo objeto de opciones en cada renderizado (p. ej.,
{ passive: true }inline), el efecto se vuelve a suscribir cada vez. Solución: Memoiza el objeto de opciones o defínelo fuera del componente. - Fase de captura — Por defecto, los listeners se adjuntan en la fase de burbuja. Solución: Pasa
{ capture: true }otruecomo parámetro de opciones para escuchar en fase de captura. - Fugas de memoria — Si bypaseas este hook y usas
addEventListenercrudo sin limpieza, los listeners se acumulan. Solución: Siempre usa este hook o empareja manualmenteaddEventListenerconremoveEventListenerenuseEffect. - Delegación de eventos — Para listas con muchos elementos, adjuntar un listener a cada elemento es derrochador. Solución: Adjunta un único listener al padre y usa
event.targetpara identificar el elemento fuente (patrón de delegación de eventos).
Alternativas
| Paquete | Nombre del Hook | Notas |
|---|---|---|
usehooks-ts | useEventListener | API sobrecargada similar |
@uidotdev/usehooks | useEventListener | Mínimo, solo window |
ahooks | useEventListener | Soporta cualquier objetivo de evento |
react-use | useEvent | API ligeramente diferente |
@react-aria/interactions | usePress, useHover | Hooks de interacción de alto nivel |
Preguntas Frecuentes
¿Por qué useEventListener almacena el handler en una ref en lugar de pasarlo directamente a addEventListener?
- El patrón de ref (
handlerRef.current) asegura que el listener siempre llame a la versión más reciente del handler sin volver a suscribirse. - Sin él, cambiar el handler requeriría remover y re-agregar el listener de evento en cada renderizado.
¿Qué sucede si no se pasa un argumento element a useEventListener?
- El hook por defecto es
windowcomo objetivo del evento. - En el servidor (SSR), verifica
typeof window === "undefined"y retorna temprano sin adjuntar nada.
¿Cómo escuchas eventos en un elemento DOM específico?
Pasa un React.RefObject como el tercer argumento:
const ref = useRef<HTMLDivElement>(null);
useEventListener("click", handleClick, ref);
return <div ref={ref}>Click me</div>;¿Cómo escuchas eventos a nivel de document como keydown?
Pasa document directamente como el tercer argumento:
useEventListener("keydown", (e) => {
console.log(e.key);
}, document);Trampa: ¿Por qué pasar un objeto options inline como { passive: true } hace que el listener se vuelva a suscribir en cada renderizado?
- Una nueva referencia de objeto se crea en cada renderizado, lo cual cambia la dependencia
optionsen el efecto. - Solución: memoiza el objeto de opciones con
useMemoo defínelo como una constante fuera del componente.
Trampa: ¿Qué sucede si el elemento ref aún no se ha montado cuando se ejecuta el efecto?
- El hook verifica
if (!element.current) returny omite adjuntar el listener. - Cuando el componente se renderiza y la ref se vuelve disponible, el efecto se ejecuta nuevamente y adjunta el listener.
¿Cuándo deberías usar la opción { passive: true }?
- Úsala para listeners de
scroll,touchstart, ytouchmovepara evitar bloquear el thread principal. - Los listeners pasivos le dicen al navegador que el handler no llamará a
preventDefault(), permitiendo desplazamiento más suave.
¿Puede useEventListener manejar eventos personalizados?
- Sí. El
eventNamebasado en string funciona con cualquier nombre de evento, incluyendo eventos personalizados. - Castea el evento a
CustomEventpara acceder a la propiedaddetail.
¿Por qué deberías preferir delegación de eventos sobre adjuntar un listener a cada elemento de lista?
- Adjuntar listeners a muchos elementos es derrochador e incrementa el uso de memoria.
- En su lugar, adjunta un listener al padre y usa
event.targetpara identificar qué elemento hijo gatilló el evento.
¿Cómo proporcionan las sobrecargas de TypeScript manejo de eventos type-safe en este hook?
- Tres firmas sobrecargadas mapean tipos de objetivo a event maps:
WindowEventMap,DocumentEventMap, yHTMLElementEventMap. - Cuando escuchas en
windowpara"keydown", el handler se tipea automáticamente como(event: KeyboardEvent) => void.
¿Cómo reducirías el tipo genérico para una ref de elemento en TypeScript?
Especifica el tipo de elemento al crear la ref:
const inputRef = useRef<HTMLInputElement>(null);
useEventListener("focus", (e) => {
// e es tipado como FocusEvent
}, inputRef);¿Se limpia automáticamente este hook el listener en desmontar?
- Sí. La función de limpieza de
useEffectllama aremoveEventListener, que se ejecuta cuando el componente se desmonta o cuando las dependencias cambian.
Relacionado
- useClickOutside — construido en listeners de eventos
- useKeyboardShortcut — especialización de eventos de teclado
- useWindowSize — listener de evento resize
- useScrollToTop — listener de evento scroll
- useIntersectionObserver — alternativa basada en observer a listeners de scroll