useOnClickOutside Hook
Close a popover when the pointer lands outside its ref. Capture phase, touch, Escape as a sibling, and ignoring the first open click.
- react
- hooks
- dom
- interview
A popover that never closes is annoying. A popover that closes on the same click that opened it is the interview bug. useOnClickOutside listens on document (usually capture pointerdown), asks ref.current.contains(event.target), and calls handler only when the answer is no.
Node.contains is the whole test. Everything else is when you attach the listener, which event you pick, and what “outside” means when the menu is in a portal while the trigger button is not.
Do not cram Escape, scroll-lock, and focus trap into this hook. Those are siblings: compose them in useDismiss or handle Escape in the dialog. Mixing them hides bugs.
- Outside means
!ref.current.contains(event.target)(andtargetis aNode). Shadow DOM and text nodes still sit under that tree.- Prefer
pointerdownin the capture phase so you see the event even when something callsstopPropagationon the bubble path.- Attach in
useEffect, not in the open click handler. Attaching in the handler is how the opening click closes the menu.- The trigger is often outside the popover ref. Clicking it is an outside click unless you pass a second ref or ignore the trigger.
- Keep the latest
handlerin a ref so the listener is not rebound every render. CleanupremoveEventListenerwith the same capture flag.
Contains is the whole test
import { useEffect, useRef } from 'react';
function useOnClickOutside(ref, handler, { enabled = true } = {}) {
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
if (!enabled) return undefined;
function onPointerDown(event) {
const node = ref.current;
if (!node) return;
const target = event.target;
if (!(target instanceof Node)) return;
if (node.contains(target)) return;
handlerRef.current(event);
}
document.addEventListener('pointerdown', onPointerDown, true);
return () => {
document.removeEventListener('pointerdown', onPointerDown, true);
};
}, [ref, enabled]);
}
Usage:
function Popover({ open, onClose, children }) {
const ref = useRef(null);
useOnClickOutside(ref, onClose, { enabled: open });
if (!open) return null;
return (
<div ref={ref} role="dialog">
{children}
</div>
);
}
contains is inclusive: the node contains itself. A click on the popover’s padding still counts as inside. A click on a child counts as inside. A click on document.body does not.
Capture, not bubble, unless you have a reason
- Capture:
window→ target - Target
- Bubble: target →
window
A listener on document in the bubble phase never runs if a descendant calls stopPropagation(). Date pickers and nested menus do that. Capture on document runs first, so you still observe the pointer. The cost: you might close while a nested overlay wanted to swallow the event. Nested overlays should use a stack, not stopPropagation, if you own both.
removeEventListener must pass the same capture flag. addEventListener(..., true) plus removeEventListener(...) with no options leaves a dangling listener (memory leaks).
Pointer, mouse, touch
| Event | Why you might pick it |
|---|---|
pointerdown |
One listener for mouse, pen, and touch in modern browsers |
mousedown |
Older codebases; misses touch |
touchstart |
Needed only if you still support browsers without Pointer Events |
click |
Fires late (after mouseup). Easy to lose a drag-outside. Also pairs badly with the opening click if you attach too early |
click is the event most people reach for. pointerdown closes faster and matches “the pointer landed.” If you must support both mouse and old iOS without Pointer Events, listen to mousedown and touchstart, and guard with a flag so one gesture does not fire handler twice.
Ignoring the first open click
If you register the document listener inside the same click handler that sets open to true, that event is still traveling. It reaches document and your new listener closes the popover in the same gesture.
// Broken: listener attached during the opening click
function onButtonClick() {
setOpen(true);
document.addEventListener('click', handleOutside);
}
useEffect runs after paint, which is after the current event finishes. The opening click is already done. That is why the hook’s effect is the right place.
Edge cases that still bite:
- Opening on
pointerdownwhile also listening topointerdownin an effect that runs inuseLayoutEffect(still in the same turn in some trees). Stick touseEffect. - The button lives outside
ref. The second click on the button is an outside click (onClose) plus the button’s own toggle (setOpen(true)). You get close-then-open flicker. Include the trigger in the contains check:
function isInside(refs, target) {
return refs.some((r) => r.current && target instanceof Node && r.current.contains(target));
}
Pass [panelRef, buttonRef].
Portals: the panel DOM is under document.body, not under the button’s parent. React events still bubble through the React tree, but contains is a DOM test. That is what you want for “pointer landed outside the panel.” Include every DOM root that should count as inside.
Escape is a sibling
useEffect(() => {
if (!open) return undefined;
function onKeyDown(e) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open, onClose]);
Keep it separate so you can disable pointer-outside on a modal that should only close on Escape and an explicit button (Accessible Modals). useOnClickOutside should not assume keydown.
Footguns
handler in the listener effect’s deps. An inline () => setOpen(false) is a new function every render, so you tear down and rebind the document listener every time. The handlerRef pattern is the same idea as useInterval: stable subscription, latest callback.
enabled: false still has to call hooks. Do not if (!open) { /* skip useOnClickOutside */ }. Pass enabled: open.
event.target is a text node. Clicking text inside the popover can give you a Text node. contains still works if you pass that node. instanceof Element checks are the ones that fail; use Node.
iframes. A pointer inside an iframe does not hit your document listeners. Click-outside will not fire until the next event in the parent document. There is no honest one-liner fix; document it.
Disabled overlay. If enabled is true while ref.current is still null (portal not mounted), every pointerdown looks outside. Gate on enabled and a mounted node, or do not set enabled until the panel is in the DOM.
Interview out-loud answer
I attach a capture-phase
pointerdownlistener ondocumentin an effect and close when!ref.current.contains(event.target). Capture still sees the event if a child stops bubbling. I keep the handler in a ref so I do not rebind on every render, and I remove the listener with the same capture flag. I attach inuseEffect, not in the open handler, so the opening click cannot close the menu. The trigger button is outside the panel ref unless I pass it too. Escape is a separate listener, not this hook.
Further reading
Related on this site
- useRef for Values and DOM
- Rules of Hooks
- useEffect Fundamentals
- Custom Hooks Design
- Capture, Bubble, once, and passive
- Event Delegation
- Walk getElementsByClassName
- Portals
- useEventListener Hook
Related guides
- useCopyToClipboard HookCopy text with navigator.clipboard, fallback execCommand, transient copied state, and permissions that fail in insecure contexts.
- useDebouncedValue HookDebounce a changing value in React without firing effects on every keystroke. Cleanup, leading vs trailing, and the stale timer that updates unmounted state.
- useEventListener HookAttach a window, document, or element listener with a stable handler ref so you do not rebind on every render. Capture option and target changes.
- useIdle HookDetect idle users: reset a timer on input events, fire idle after timeout, and decide whether mount starts the timer immediately.
- useInfiniteScroll HookLoad the next page when a sentinel enters the viewport. IntersectionObserver, hasMore, in-flight guards, and the footer that never intersects.