Dropdowns, popovers and date pickers share one behavior: a click anywhere else closes them. The Hook takes a ref to the element that counts as "inside" and a callback for everything else.
import { useEffect } from 'react';
export function useOutsideAlerter(ref, onOutside) {
useEffect(() => {
function handle(event) {
if (event.key === 'Escape') return onOutside(event);
if (ref.current && !ref.current.contains(event.target)) onOutside(event);
}
const types = ['pointerdown', 'keydown'];
types.forEach(t => document.addEventListener(t, handle));
return () => types.forEach(t => document.removeEventListener(t, handle));
}, [ref, onOutside]);
}A dropdown puts a ref on its panel, keeps an open flag in state, and passes useCallback(() => setOpen(false), []) as onOutside.
pointerdown is deliberate: a click listener fires only after release, by which time a drag that began inside the panel and ended outside it would close the panel under the user's finger, and pointerdown covers touch and pen too. onOutside is memoized at the call site because it sits in the dependency array — an inline arrow would detach and reattach both listeners on every render; useEffectEvent (Separating Events from Effects) removes that requirement.
One blind spot: contains walks the DOM tree, and a panel rendered through a portal (Portals) is not a DOM descendant of the component that owns it, so every click on it looks outside. Keep a second ref to the portal node and test both. Before writing any of this, check whether the platform already does it: an auto popover (<div popover>, Baseline since April 2024) and <dialog> both light-dismiss on an outside click and on Escape, with no listener and no ref at all.