useClickOutside — Detect clicks outside a referenced element to dismiss UI
Recipe
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.
Working Example
"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:
- Single ref pattern: the hook returns a ref you attach to the container element
- Multi-ref pattern: pass an array of refs so clicks on either the trigger or popover are considered "inside"
mousedownfires beforeclick, giving snappier dismiss behavior- Touch events are handled for mobile devices
Deep Dive
How It Works
mousedownvsclick: Usingmousedown(andtouchstart) lets the dismiss happen before the click completes. This prevents issues where a click handler on the trigger re-opens a just-closed dropdown.containscheck:Node.contains()returnstrueif the event target is the element itself or any descendant, covering nested children.- Handler ref pattern: Storing the handler in a ref avoids re-subscribing event listeners every time the handler identity changes (common with inline arrow functions).
- Multiple refs: When the trigger and popover are separate DOM nodes (e.g., portals), passing an array of refs ensures clicks on either are treated as "inside."
Parameters & Return Values
| 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) |
Variations
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
};TypeScript Notes
- The generic
T extends HTMLElementdefaults toHTMLElementbut can be narrowed:useClickOutside<HTMLDivElement>(...). - The handler receives the union
MouseEvent | TouchEventsince both event types are listened to. - The ref array uses
React.RefObject<T | null>[]for compatibility withuseRef<T>(null).
Gotchas
- Portaled content — If the dropdown renders in a React portal, it may be outside the ref's DOM tree even though it is logically "inside." Fix: Use the multi-ref pattern, passing both the trigger ref and the portal content ref.
- Third-party overlays — Click on a toast or notification can trigger dismiss. Fix: Use the
shouldIgnorevariation or check against a class name. - Event ordering —
mousedownfires beforeclick. If the trigger usesonClickto 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. - SVG elements —
containsmay not work correctly with SVG elements in some browsers. Fix: Useevent.target.closest()as an alternative check. - iOS Safari — Touch events may behave differently. Fix: Listening to
touchstartalongsidemousedownhandles this.
Alternatives
| 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 |
FAQs
Why does the hook listen for mousedown instead of click?
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.
How does the multi-ref pattern work and when should I use it?
- Pass an array of refs to treat multiple DOM nodes as "inside."
- The handler only fires if the click is outside all refs.
- Use it when the trigger and popover are separate DOM nodes (e.g., with portals).
What does Node.contains() check in the listener?
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.
Why is the handler stored in a ref instead of passed directly to the listener?
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.
Gotcha: My dropdown renders in a React portal and clicks on it trigger dismiss. Why?
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.
Gotcha: Clicking a toast notification closes my dropdown. How do I prevent this?
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);
};Does this hook work on mobile devices with touch events?
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.
How do I also dismiss on Escape key press?
Add a keydown listener for the Escape key inside the same effect or use useKeyboardShortcut:
useKeyboardShortcut("Escape", () => setIsOpen(false), {
enabled: isOpen,
});How does the generic T extends HTMLElement constrain the ref type in TypeScript?
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.
What is the type of the event parameter in the handler?
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.
Related
- useKeyboardShortcut — add Escape key dismiss
- useEventListener — the underlying listener pattern
- useToggle — manage open/close state