useClickOutside — Detecta clics fuera de un elemento referenciado para descartar la interfaz
Receta
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.
Ejemplo funcional
"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:
- Patrón de ref única: el hook devuelve una ref que adjuntas al elemento contenedor
- Patrón de múltiples refs: pasa un array de refs para que los clics en el activador o popover se consideren "dentro"
mousedownse dispara antes declick, proporcionando un comportamiento de cierre más rápido- Los eventos táctiles se manejan para dispositivos móviles
Análisis profundo
Cómo funciona
mousedownvsclick: Usarmousedown(ytouchstart) 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.- Verificación
contains:Node.contains()devuelvetruesi el objetivo del evento es el elemento mismo o cualquier descendiente, cubriendo niños anidados. - Patrón de ref de manejador: Almacenar el manejador en una ref evita volver a suscribirse a listeners de eventos cada vez que la identidad del manejador cambia (común con funciones flecha en línea).
- Múltiples refs: Cuando el activador y popover son nodos DOM separados (p. ej., portales), pasar un array de refs asegura que los clics en cualquiera se traten como "dentro".
Parámetros y valores de retorno
| 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) |
Variaciones
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
};Notas de TypeScript
- El genérico
T extends HTMLElementpor defecto esHTMLElementpero puede estrecharse:useClickOutside<HTMLDivElement>(...). - El manejador recibe la unión
MouseEvent | TouchEventya que ambos tipos de eventos se escuchan. - El array de refs usa
React.RefObject<T | null>[]para compatibilidad conuseRef<T>(null).
Trampas
- Contenido portalizado — Si el menú desplegable se renderiza en un portal de React, puede estar fuera del árbol DOM de la ref aunque sea lógicamente "dentro". Solución: Usa el patrón de múltiples refs, pasando tanto la ref del activador como la ref del contenido del portal.
- Superposiciones de terceros — Hacer clic en un toast o notificación puede activar el cierre. Solución: Usa la variación
shouldIgnoreo verifica contra un nombre de clase. - Orden de eventos —
mousedownse dispara antes declick. Si el activador usaonClickpara 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. - Elementos SVG —
containspuede no funcionar correctamente con elementos SVG en algunos navegadores. Solución: Usaevent.target.closest()como verificación alternativa. - iOS Safari — Los eventos táctiles pueden comportarse de manera diferente. Solución: Escuchar
touchstartjunto conmousedownmaneja esto.
Alternativas
| 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 |
Preguntas frecuentes
¿Por qué el hook escucha mousedown en lugar de click?
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.
¿Cómo funciona el patrón de múltiples refs y cuándo debo usarlo?
- Pasa un array de refs para tratar múltiples nodos DOM como "dentro".
- El manejador solo se dispara si el clic está fuera de todas las refs.
- Úsalo cuando el activador y popover son nodos DOM separados (p. ej., con portales).
¿Qué verifica Node.contains() en el listener?
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.
¿Por qué el manejador se almacena en una ref en lugar de pasarse directamente al listener?
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.
Trampa: Mi menú desplegable se renderiza en un portal de React y los clics lo cierran. ¿Por qué?
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.
Trampa: Hacer clic en una notificación de toast cierra mi menú desplegable. ¿Cómo lo prevengo?
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);
};¿Funciona este hook en dispositivos móviles con eventos táctiles?
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.
¿Cómo también cierro al presionar la tecla Escape?
Agrega un listener keydown para la tecla Escape dentro del mismo efecto o usa useKeyboardShortcut:
useKeyboardShortcut("Escape", () => setIsOpen(false), {
enabled: isOpen,
});¿Cómo el genérico T extends HTMLElement constriñe el tipo de ref en TypeScript?
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.
¿Cuál es el tipo del parámetro de evento en el manejador?
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.
Relacionado
- useKeyboardShortcut — agrega cierre de tecla Escape
- useEventListener — el patrón de listener subyacente
- useToggle — gestionar estado abierto/cerrado