Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
import { useEffect, useRef, useCallback } from "react";
/**
* useClickOutside
* Calls `handler` when a click (or touch) occurs outside the referenced element.
* Supports a single ref or an array of refs (e.g., trigger + 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);
// Keep handler fresh without re-subscribing
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
const allRefs = refs ? refs : [singleRef];
const listener = (event: MouseEvent | TouchEvent) => {
// Check if click is inside any of the referenced elements
const isInside = allRefs.some((ref) => {
return ref.current?.contains(event.target as Node);
});
if (!isInside) {
handlerRef.current(event);
}
};
// Use mousedown/touchstart for immediate response
// (before the click event, which fires on mouse up)
document.addEventListener("mousedown", listener);
document.addEventListener("touchstart", listener);
return () => {
document.removeEventListener("mousedown", listener);
document.removeEventListener("touchstart", listener);
};
}, [refs]);
return singleRef;
}When to reach for this: You have a dropdown, modal, popover, or context menu that should close when the user clicks anywhere outside of it.
"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)}>
Menu {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>Profile</li>
<li>Settings</li>
<li>Logout</li>
</ul>
)}
</div>
);
}
// Multi-ref example: trigger button + popover are both "inside"
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)}>
Open 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>Popover content</p>
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
)}
</>
);
}What this demonstrates:
mousedown fires before click, giving snappier dismiss behaviormousedown vs click: Using mousedown (and touchstart) lets the dismiss happen before the click completes. This prevents issues where a click handler on the trigger re-opens a just-closed dropdown.contains check: Node.contains() returns true if the event target is the element itself or any descendant, covering nested children.| Parameter | Type | Default | Description |
|---|---|---|---|
handler | (event: MouseEvent or TouchEvent) => void | - | Called when an outside click is detected |
refs | RefObject<T>[] | - | Optional array of refs to treat as "inside" |
| Returns | RefObject<T> | - | A ref to attach (used when refs is not provided) |
With escape key: Add keyboard dismiss alongside click-outside:
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") handlerRef.current(e as any);
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, []);Ignore certain elements: Accept a shouldIgnore predicate to skip dismiss for specific elements (e.g., toasts, modals that stack):
const listener = (event: MouseEvent) => {
if (shouldIgnore?.(event.target as HTMLElement)) return;
// ...existing logic
};T extends HTMLElement defaults to HTMLElement but can be narrowed: useClickOutside<HTMLDivElement>(...).MouseEvent | TouchEvent since both event types are listened to.React.RefObject<T | null>[] for compatibility with useRef<T>(null).shouldIgnore variation or check against a class name.mousedown fires before click. If the trigger uses onClick to toggle open, the outside-click handler may close it before the toggle re-opens it. Fix: Wrap the container and trigger in the same ref, or use multi-ref.contains may not work correctly with SVG elements in some browsers. Fix: Use event.target.closest() as an alternative check.touchstart alongside mousedown handles this.| Package | Hook Name | Notes |
|---|---|---|
usehooks-ts | useOnClickOutside | Similar API, well-tested |
@uidotdev/usehooks | useClickAway | Minimal implementation |
ahooks | useClickAway | Supports multiple refs natively |
react-aria | useInteractOutside | Accessible, handles focus and pointer |
| Headless UI | Built-in | Dropdowns and popovers auto-dismiss |
mousedown fires before click (which fires on mouse up). This gives snappier dismiss behavior and prevents a race condition where a click handler on the trigger could re-open a just-closed dropdown.
ref.current.contains(event.target) returns true if the click target is the element itself or any of its descendants. This covers clicks on nested children inside the referenced container.
Inline arrow functions create a new reference every render. Storing the handler in a ref avoids re-subscribing the document event listeners on each render while always calling the latest version.
Portal content is outside the ref's DOM tree even though it is logically "inside" the component. Use the multi-ref pattern, passing both the trigger ref and the portal content ref to the hook.
Use the shouldIgnore variation to skip dismiss for specific elements:
const listener = (event: MouseEvent) => {
if ((event.target as HTMLElement).closest(".toast")) return;
handlerRef.current(event);
};Yes. The hook listens for both mousedown and touchstart, so taps on mobile devices are handled. Touch events fire before mouse events on touch devices.
Add a keydown listener for the Escape key inside the same effect or use useKeyboardShortcut:
useKeyboardShortcut("Escape", () => setIsOpen(false), {
enabled: isOpen,
});The generic defaults to HTMLElement but can be narrowed. For example, useClickOutside<HTMLDivElement>(handler) returns a RefObject<HTMLDivElement | null>, giving you type-safe access to div-specific properties.
The handler receives MouseEvent | TouchEvent since both mousedown and touchstart are listened to. You can narrow the type inside the handler with instanceof checks if needed.
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥