React useClickOutside Hook
Dropdowns, popovers, and modals all need to dismiss when the user clicks outside their wrapper. The useClickOutside hook centralises the listener wiring so feature components stay declarative. This snippet covers the pointerdown-based version, an ignore-list variant for portal triggers, and the keyboard escape sibling that completes the dismissal pattern.
270 views
6
function useClickOutside(ref, handler) {
const handlerRef = useRef(handler);
useEffect(() => { handlerRef.current = handler; }, [handler]);
useEffect(() => {
if (typeof document === 'undefined') return undefined;
const onPointerDown = (e) => {
const node = ref && ref.current;
if (!node || node.contains(e.target)) return;
handlerRef.current(e);
};
document.addEventListener('pointerdown', onPointerDown, true);
return () => document.removeEventListener('pointerdown', onPointerDown, true);
}, [ref]);
}
function useRef(v) { return { current: v }; }
function useEffect(fn) { fn(); }
const popoverRef = { current: { contains: () => false } };
useClickOutside(popoverRef, () => console.log('dismiss'));
console.log('outside listener attached');Listening on pointerdown (capture phase) catches the press before any click handler inside the popover runs, which avoids race conditions with stopPropagation. The hook bails out early if the target lives inside ref.current, which uses the DOM Node.contains check. Capture phase plus pointerdown also handles touch correctly without a separate listener. This is the right shape for popovers, autocomplete dropdowns, and any UI that should dismiss on a click anywhere else.
function useClickOutsideIgnore(ref, handler, ignoreRefs = []) {
const handlerRef = useRef(handler);
useEffect(() => { handlerRef.current = handler; }, [handler]);
useEffect(() => {
if (typeof document === 'undefined') return undefined;
const onDown = (e) => {
const inside = ref.current && ref.current.contains(e.target);
if (inside) return;
for (const r of ignoreRefs) {
if (r && r.current && r.current.contains(e.target)) return;
}
handlerRef.current(e);
};
document.addEventListener('pointerdown', onDown, true);
return () => document.removeEventListener('pointerdown', onDown, true);
}, [ref, ignoreRefs]);
}
const panel = { current: { contains: () => false } };
const trigger = { current: { contains: () => false } };
useClickOutsideIgnore(panel, () => console.log('dismiss'), [trigger]);
console.log('ignore-list listener attached');When the trigger button lives outside the panel (a portal-rendered popover, a tooltip with a separate target), clicking the trigger should reopen rather than dismiss. The ignore list is a list of refs that count as 'still inside' for dismissal purposes. The order of checks matters: bail out for the panel first (cheap), then walk the ignore list. This is the same shape Radix and Headless UI use under the hood, just compressed into a few lines for cases where you do not want a full library.
function useDismiss(ref, onDismiss) {
useClickOutside(ref, onDismiss);
useEffect(() => {
if (typeof document === 'undefined') return undefined;
const onKey = (e) => { if (e.key === 'Escape') onDismiss(e); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onDismiss]);
}
const dialogRef = { current: { contains: () => false } };
useDismiss(dialogRef, () => console.log('dismissed'));
console.log('dismiss combo attached');Outside-click and Escape-key dismissal almost always go together: a user expects either gesture to close the same UI. Wrapping both into one useDismiss hook keeps consumers from forgetting one. Listening on keydown (not keypress, which is deprecated) catches the modifier even when an input inside the popover has focus. Together with the focus-trap pattern, this trio is the basic accessibility contract for any dismissable overlay.
