skip to content

Write a React `useOnClickOutside(ref, handler)` hook that closes a dropdown when the user clicks elsewhere. Which document event should it listen for, and how do you stop a new inline `handler` from tearing down and re-attaching the listener on every render?

level: seniorimportance: should knowfreq 38%

answer

  1. press time, not release time
  2. contains asks about the DOM, not React
  3. the caller will pass an inline arrow
  4. depend on nothing that changes
  5. the listener outlives the component

basics

~20 s

Attach a document listener for pointerdown, ignore the event when ref.current contains event.target, and remove the listener in effect cleanup. Keep the handler in a ref refreshed each commit so the subscribing effect depends only on the ref, not on a handler identity that changes every render.

solid answer

~40 s

Listen on `document` for `pointerdown` rather than `click`. Pointerdown fires at the start of the interaction, so the menu closes before the click completes, and it covers mouse, touch and pen in one listener. Inside, bail out with `if (!ref.current || ref.current.contains(event.target)) return;` and otherwise call the handler. The effect returns a cleanup calling `removeEventListener` so nothing survives unmount. The re-attachment problem comes from callers passing an inline arrow: a new `handler` identity each render puts the effect's dependency array in flux, so React removes and re-adds the listener on every render. Rather than demand the caller memoize, keep the handler in a ref that a separate dependency-free effect updates after each commit, and have the listener call `handlerRef.current(event)`. The subscribe effect then has stable dependencies and runs once.

code

javascript · 25 lines
javascript
import { useEffect, useRef } from 'react';

export function useOnClickOutside(ref, handler) {
  const handlerRef = useRef(handler);
  useEffect(() => {
    handlerRef.current = handler;
  });

  useEffect(() => {
    const listener = event => {
      const el = ref.current;
      if (!el || el.contains(event.target)) return;
      handlerRef.current(event);
    };
    const onKey = event => {
      if (event.key === 'Escape') handlerRef.current(event);
    };
    document.addEventListener('pointerdown', listener);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('pointerdown', listener);
      document.removeEventListener('keydown', onKey);
    };
  }, [ref]);
}

go deeper

for a junior

Know the shape: an effect adds a document listener, the listener compares event.target against ref.current with contains, and the effect returns a function that removes the listener.

for a middle

Explain why pointerdown beats click, why the null check on ref.current comes first, and why an inline handler prop makes the effect tear down and re-subscribe on every render.

for a senior

Demonstrate the latest-ref pattern and defend it against the two weaker options — demanding caller memoization, or lying to the dependency array. Bring up portals, the trigger element, and Escape without being prompted.

for a principal

Own it as a platform decision: dismissal behaviour should be one audited primitive rather than per-feature copies, weighed against the native popover and dialog capabilities and against accessibility requirements for focus management and inert background content.

## The hook ```js import { useEffect, useRef } from 'react'; function useOnClickOutside(ref, handler) { const handlerRef = useRef(handler); useEffect(() => { handlerRef.current = handler; }); useEffect(() => { const listener = event => { const el = ref.current; if (!el || el.contains(event.target)) return; handlerRef.current(event); }; document.addEventListener('pointerdown', listener); return () => document.removeEventListener('pointerdown', listener); }, [ref]); } ``` ## Which event, and why it matters `click` fires after the pointer is released, which produces two real bugs. First, the menu stays open for the whole press, which feels laggy on touch. Second, if the user presses inside the menu and releases outside — a drag, or a text selection that runs past the edge — the click target is the common ancestor and the menu closes when it should not. `pointerdown` fires at press time, is a single event covering mouse, touch and pen, and matches how native menus behave. The trade-off worth naming out loud: closing on pointerdown means an outside button's own click may land on a page that has already re-laid-out, because the menu closed between the press and the release. Whether that matters depends on the layout; if elements shift, closing on `pointerup` or `click` is the deliberate alternative. An interviewer is listening for the awareness, not a fixed answer. A complete implementation also handles `keydown` for `Escape`, since a menu that closes only on an outside click is not keyboard-operable. ## The re-attachment problem Callers write `useOnClickOutside(ref, () => setOpen(false))`. That arrow is a new function object on every render. If the subscribing effect lists `handler` in its dependencies, React runs cleanup and setup on every single render: remove the listener, add it again, forever. It is not *incorrect* — the behaviour is right — but it is pure churn, and it makes the hook's cost proportional to how often its owner renders. The alternatives are worth ranking. Pushing the problem to the caller ("wrap your handler in `useCallback`") makes a shared hook unpleasant to use and one forgotten memo re-introduces the churn silently. Omitting `handler` from the dependency array while still closing over it is worse: the listener then permanently captures the first render's handler and reads stale state — the classic stale-closure bug, and one the exhaustive-deps lint rule will flag. The latest-ref pattern resolves both. A ref is a mutable box that does not participate in rendering, so refreshing `handlerRef.current` after each commit does not invalidate anything, and the listener resolves the function at *call* time rather than capturing it at *subscribe* time. The subscribe effect's only dependency is `ref`, which is itself stable for the component's lifetime, so the listener is attached exactly once. React has an in-progress `useEffectEvent` hook aimed at expressing precisely this "read the latest, do not depend on it" intent as a first-class API; until it is stable in the version you target, the ref is the portable spelling. ## Cleanup is not optional A `document`-level listener outlives the component unless removed. Every mount of a dropdown that forgets cleanup leaves a listener holding the component's closure — a leak that also produces phantom behaviour, since the stale listener still calls a handler for a menu that no longer exists. Returning the remover from the effect is the whole fix, and it is also what makes StrictMode's development-only double mount harmless: setup, cleanup, setup leaves exactly one listener. ## Where `contains` betrays you `Node.contains` asks about the DOM tree, not the React tree. Content rendered through a portal is a React child of your component but a DOM child of wherever the portal mounted — usually the document body. So a dropdown panel rendered into a portal is *not* contained by the trigger's ref, and clicking inside it closes the menu. The remedy is to check every relevant element: accept an array of refs, or have the panel itself register the ref the hook checks. A second trap is the element that opened the menu. If the trigger button is outside the ref, its own pointerdown counts as "outside", the handler closes the menu, and the button's click then reopens it — a menu that appears not to open at all. Either include the trigger in the checked elements or make the trigger toggle rather than open. ## Iframes and shadow DOM A click inside an `<iframe>` never dispatches an event in the parent document, so the menu stays open. Inside a shadow root, `event.target` is retargeted to the host element, so `contains` reports containment at host granularity; `event.composedPath()` is the API that gives the real path for composed events. These are the details that separate a hook that works in a demo from one that works in an application shell.

  • The dropdown panel is rendered with createPortal into the document body. Why does the hook close it on every internal click?
    Because `ref.current.contains(event.target)` walks the DOM tree, and a portalled panel is a DOM child of the body rather than of the trigger, even though it is a React child. The containment check returns false and the click reads as outside. Fix it by checking the panel's own ref as well — accept an array of refs, or check `event.composedPath()` against several elements.
  • Why not just tell callers to wrap their handler in useCallback?
    It works, but it exports the hook's implementation detail as a usage rule, and a single forgotten memo silently reintroduces attach/detach on every render with no visible symptom. A shared hook should be correct and cheap for the obvious call site — an inline arrow — which is exactly what the latest-ref pattern buys.
  • What goes wrong if the trigger button sits outside the ref you pass?
    Its own pointerdown is classified as outside, so the handler closes the menu, and then the button's click handler opens it again — or, if the button only opens, the menu appears never to open. Include the trigger among the checked elements, or make the trigger a toggle so the two effects compose predictably.
  • How would you also close the menu on Escape?
    Add a `keydown` listener in the same effect and call the handler when the key is Escape, removing both listeners in the one cleanup. It is not optional polish: a menu dismissible only by pointer is unusable by keyboard, and screen-reader users expect Escape to close a transient overlay.

saying these in an interview costs you the question

  • Listens on 'click' and dismisses the drag-out selection case
  • Omits handler from the dependency array while closing over it
  • Never removes the document listener on unmount
  • Assumes contains() sees portalled content
  • Says the trigger button never needs special handling

context