Busca en todas las páginas de la documentación
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
import { useEffect, useRef, useCallback } from "react";
/**
* useClickOutside
* Llama a `handler` cuando ocurre un clic (o toque) fuera del elemento referenciado.
* Admite una única ref o un array de refs (p. ej., activador + popover).
*/
function useClickOutside<T extends HTMLElement = HTMLElement>(
handler: (event: MouseEvent | TouchEvent) => void,
refs?: React.RefObject<T | null>[]
): React.RefObject<T | null> {
const singleRef = useRef<T | null>(null);
const handlerRef = useRef(handler);
// Mantén el manejador fresco sin volver a suscribirse
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
const allRefs = refs ? refs : [singleRef];
const listener = (event: MouseEvent | TouchEvent) => {
// Verifica si el clic está dentro de cualquiera de los elementos referenciados
const isInside = allRefs.some((ref) => {
return ref.current?.contains(event.target as Node);
});
if (!isInside) {
handlerRef.current(event);
}
};
// Usa mousedown/touchstart para una respuesta inmediata
// (antes del evento click, que se dispara al liberar el ratón)
document.addEventListener("mousedown", listener);
document.addEventListener("touchstart", listener);
return () => {
document.removeEventListener("mousedown", listener);
document.removeEventListener("touchstart", listener);
};
}, [refs]);
return singleRef;
}Cuándo usarlo: Tienes un menú desplegable, modal, popover o menú contextual que debe cerrarse cuando el usuario hace clic en cualquier lugar fuera de él.
"use client";
import { useState, useRef } from "react";
function Dropdown() {
const [isOpen, setIsOpen] = useState(false);
const ref = useClickOutside<HTMLDivElement>(() => setIsOpen(false));
return (
<div ref={ref} style={{ position: "relative", display: "inline-block" }}>
<button onClick={() => setIsOpen((o) => !o)}>
Menú {isOpen ? "▲" : "▼"}
</button>
{isOpen && (
<ul
style={{
position: "absolute",
top: "100%",
left: 0,
background: "#fff",
border: "1px solid #ccc",
listStyle: "none",
padding: 8,
margin: 0,
minWidth: 150,
boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
}}
>
<li>Perfil</li>
<li>Configuración</li>
<li>Cerrar sesión</li>
</ul>
)}
</div>
);
}
// Ejemplo de múltiples refs: el botón activador + popover están ambos "dentro"
function PopoverWithTrigger() {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
useClickOutside(() => setIsOpen(false), [triggerRef, popoverRef]);
return (
<>
<button ref={triggerRef} onClick={() => setIsOpen((o) => !o)}>
Abrir Popover
</button>
{isOpen && (
<div
ref={popoverRef}
style={{
position: "absolute",
padding: 16,
background: "#fff",
border: "1px solid #e0e0e0",
borderRadius: 8,
boxShadow: "0 8px 24px rgba(0,0,0,0.12)",
}}
>
<p>Contenido del popover</p>
<button onClick={() => setIsOpen(false)}>Cerrar</button>
</div>
)}
</>
);
}Lo que esto demuestra:
mousedown se dispara antes de click, proporcionando un comportamiento de cierre más rápidomousedown vs click: Usar mousedown (y touchstart) permite que el cierre ocurra antes de que se complete el clic. Esto evita problemas donde un manejador de clics en el activador reabre un menú desplegable que acaba de cerrarse.contains: Node.contains() devuelve true si el objetivo del evento es el elemento mismo o cualquier descendiente, cubriendo niños anidados.| Parámetro | Tipo | Por defecto | Descripción |
|---|---|---|---|
handler | (event: MouseEvent or TouchEvent) => void | - | Se llama cuando se detecta un clic externo |
refs | RefObject<T>[] | - | Array opcional de refs para tratar como "dentro" |
| Retorna | RefObject<T> | - | Una ref para adjuntar (se usa cuando refs no se proporciona) |
Con tecla Escape: Agrega cierre por teclado junto con clic fuera:
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") handlerRef.current(e as any);
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, []);Ignorar ciertos elementos: Acepta un predicado shouldIgnore para omitir el cierre de elementos específicos (p. ej., toasts, modales apilados):
const listener = (event: MouseEvent) => {
if (shouldIgnore?.(event.target as HTMLElement)) return;
// ...lógica existente
};T extends HTMLElement por defecto es HTMLElement pero puede estrecharse: useClickOutside<HTMLDivElement>(...).MouseEvent | TouchEvent ya que ambos tipos de eventos se escuchan.React.RefObject<T | null>[] para compatibilidad con useRef<T>(null).shouldIgnore o verifica contra un nombre de clase.mousedown se dispara antes de click. Si el activador usa onClick para alternar abierto/cerrado, el manejador de clics externos puede cerrarlo antes de que el activador lo reabre. Solución: Envuelve el contenedor y el activador en la misma ref, o usa múltiples refs.contains puede no funcionar correctamente con elementos SVG en algunos navegadores. Solución: Usa event.target.closest() como verificación alternativa.touchstart junto con mousedown maneja esto.| Paquete | Nombre del Hook | Notas |
|---|---|---|
usehooks-ts | useOnClickOutside | API similar, bien probado |
@uidotdev/usehooks | useClickAway | Implementación mínima |
ahooks | useClickAway | Admite múltiples refs de forma nativa |
react-aria | useInteractOutside | Accesible, maneja enfoque y puntero |
| Headless UI | Incorporado | Menús desplegables y popovers se cierran automáticamente |
mousedown se dispara antes de click (que se dispara al liberar el ratón). Esto proporciona un comportamiento de cierre más rápido y previene una condición de carrera donde un manejador click en el activador podría reabrir un menú desplegable que acaba de cerrarse.
ref.current.contains(event.target) devuelve true si el objetivo del clic es el elemento mismo o cualquiera de sus descendientes. Esto cubre clics en niños anidados dentro del contenedor referenciado.
Las funciones flecha en línea crean una nueva referencia en cada renderizado. Almacenar el manejador en una ref evita volver a suscribirse a los listeners de eventos del documento en cada renderizado mientras siempre llama a la versión más reciente.
El contenido del portal está fuera del árbol DOM de la ref aunque sea lógicamente "dentro" del componente. Usa el patrón de múltiples refs, pasando tanto la ref del activador como la ref del contenido del portal al hook.
Usa la variación shouldIgnore para omitir el cierre de elementos específicos:
const listener = (event: MouseEvent) => {
if ((event.target as HTMLElement).closest(".toast")) return;
handlerRef.current(event);
};Sí. El hook escucha tanto mousedown como touchstart, por lo que los toques en dispositivos móviles se manejan. Los eventos táctiles se disparan antes de los eventos del ratón en dispositivos táctiles.
Agrega un listener keydown para la tecla Escape dentro del mismo efecto o usa useKeyboardShortcut:
useKeyboardShortcut("Escape", () => setIsOpen(false), {
enabled: isOpen,
});El genérico por defecto es HTMLElement pero puede estrecharse. Por ejemplo, useClickOutside<HTMLDivElement>(handler) devuelve un RefObject<HTMLDivElement | null>, dándote acceso seguro de tipos a propiedades específicas de div.
El manejador recibe MouseEvent | TouchEvent ya que ambos mousedown y touchstart se escuchan. Puedes estrechar el tipo dentro del manejador con verificaciones instanceof si es necesario.
Revisado por Chris St. John·Última actualización: 16 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥