ESC

Type to search the knowledge base.

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.

intermediate6 min read
  • 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) (and target is a Node). Shadow DOM and text nodes still sit under that tree.
  • Prefer pointerdown in the capture phase so you see the event even when something calls stopPropagation on 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 handler in a ref so the listener is not rebound every render. Cleanup removeEventListener with 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 vs bubble:

  1. Capture: window → target
  2. Target
  3. 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 pointerdown while also listening to pointerdown in an effect that runs in useLayoutEffect (still in the same turn in some trees). Stick to useEffect.
  • 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 pointerdown listener on document in 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 in useEffect, 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 guides