skip to content

A dropdown closes on outside clicks using a document listener that ignores clicks where wrapperRef.current.contains(event.target). After the menu is moved into createPortal(menu, document.body), it now closes the instant you click an item inside it. What changed, and how do you fix it?

level: seniorimportance: should knowfreq 35%

answer

  1. contains() is a DOM question
  2. the menu left the wrapper's subtree
  3. one component, two DOM locations
  4. two refs, not one
  5. pointerdown beats click here

basics

~20 s

Node.contains walks the DOM, and the portalled menu is no longer a DOM descendant of the wrapper, so inside clicks now look outside. Fix it by testing containment against both the trigger element and the portalled content, each with its own ref.

solid answer

~50 s

The outside-click test is a DOM-ancestry test, and the portal broke the DOM ancestry. Before, the menu was inside the wrapper element, so `wrapperRef.current.contains(event.target)` was true for clicks on menu items. After portalling, the menu's nodes live under `document.body`, so `contains` returns false and the handler treats every inside click as an outside click. The straightforward fix is to keep a second ref on the portalled content's root node and treat a click as inside if either the trigger or that content contains the target. Alternatives are to render a full-screen backdrop element inside the portal and close from its own React `onClick`, or to mark inside-clicks using React's propagation, which still reaches the owning component. What does not fix it is raising `z-index` or switching the document listener to capture phase — neither changes DOM ancestry.

code

javascript · 36 lines
javascript
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';

export default function Menu() {
  const [open, setOpen] = useState(false);
  const triggerRef = useRef(null);
  const contentRef = useRef(null);

  useEffect(() => {
    if (!open) return;
    function onPointerDown(event) {
      const target = event.target;
      if (triggerRef.current?.contains(target)) return;
      if (contentRef.current?.contains(target)) return;
      setOpen(false);
    }
    document.addEventListener('pointerdown', onPointerDown);
    return () => document.removeEventListener('pointerdown', onPointerDown);
  }, [open]);

  return (
    <>
      <button ref={triggerRef} onClick={() => setOpen((o) => !o)}>
        Menu
      </button>
      {open &&
        createPortal(
          <ul ref={contentRef} className="menu">
            <li>Rename</li>
            <li>Delete</li>
          </ul>,
          document.body,
        )}
    </>
  );
}

go deeper

for a junior

Recall that contains() asks a DOM question, and a portal puts the content somewhere else in the DOM — so an inside click can look outside.

for a middle

Explain the fix concretely: a ref on the portalled content as well as the trigger, checked together, with the listener attached only while open and cleaned up on close.

for a senior

Diagnose it out loud — React behaviour unchanged, DOM ancestry changed — then weigh the options: two refs, a backdrop inside the portal, or React propagation, and say why stopPropagation is a trap that disables unrelated document listeners.

for a principal

Generalise the failure: any overlay in a design system spans two DOM locations, so dismissal, focus containment, aria relationships and test queries all need one shared primitive rather than per-component patches.

## Why it broke The outside-click idiom is a DOM question asked in DOM terms: *is the clicked node inside my subtree?* `Node.contains()` answers strictly by DOM ancestry. Rendering the menu through a portal moved the menu's DOM out of the wrapper and into `document.body`, so the wrapper's subtree no longer includes it. Every click on a menu item is now, by that test, an outside click — and the menu closes before its own item handler has any useful effect. The reason this is confusing in review is that nothing about the *React* code looks different: the menu is still written inside the same component, still reads the same state, and its React `onClick` handlers still run. Only the invisible half — DOM ancestry — moved. This is the general rule to carry: after portalling, anything expressed in DOM terms (`contains`, `closest`, descendant CSS selectors, native bubbling, focus order) changes, and anything expressed in React terms does not. ## The fix that scales Give the portalled content its own ref and test both nodes: ```jsx function isInside(event, ...refs) { return refs.some((r) => r.current?.contains(event.target)); } ``` Then close only when neither the trigger nor the portalled content contains the target. Including the trigger matters: without it, clicking the trigger to close would run the document listener (closing) and the trigger's own handler (toggling open again), and the menu would appear stuck. Two further details make this reliable in practice. **Use `pointerdown` rather than `click`.** If the listener is attached inside an effect that runs during the very click that opened the menu, a `click` listener can catch the tail of that same event and close it immediately. `pointerdown` also matches how native menus feel — they dismiss as soon as you press elsewhere. **Add and remove the listener only while open**, from an effect whose cleanup calls `removeEventListener`. A permanently attached document listener that runs on every click in the app is both wasteful and a source of cross-component surprises. ## The alternatives, and when they are better **A backdrop inside the portal.** Render a full-screen element as the first child of the portal and close from its React `onClick`. No document listener at all, and it composes cleanly with focus trapping. The cost is a real element that intercepts pointer events, which is exactly right for a modal and usually wrong for a tooltip. **Mark inside-clicks with React propagation.** Because portalled content is still in the React tree, a click inside the menu bubbles synthetically to the component that rendered it. You can set a flag there and let the document listener skip that dispatch. It works, but it couples two propagation systems and is harder to read than two refs. **`event.stopPropagation()` in a React handler on the menu.** This looks like the smallest fix and is the most dangerous one. React's synthetic `stopPropagation` also stops the native event, so the document listener never runs — but so do any other document-level listeners in the app, such as analytics, other dismissable layers, or a global keyboard/pointer manager. You have fixed one dropdown by silently disabling unrelated features. ## Things that do not fix it Raising `z-index` addresses painting, not ancestry. Switching the document listener to the capture phase changes only *when* it runs, not what `contains` reports — it usually makes matters worse by firing before the menu's own handler. Reverting the portal fixes the containment check but reintroduces the clipping or stacking problem the portal was added to solve. ## The wider lesson Senior candidates are expected to generalise from this: portals split a component into two DOM locations, so any code that reasons about the component's DOM extent has to be told about both. That includes outside-click dismissal, focus containment (`document.activeElement` checks and focus traps must span trigger and content), `aria-controls`/`aria-owns` relationships that assume proximity, and tests that query within a container element rather than the whole document. A component library usually centralises this in one dismissable-layer primitive rather than reimplementing the two-ref dance in every overlay.

  • Why include the trigger element in the containment check as well as the portalled content?
    Otherwise clicking the trigger while the menu is open runs both the document listener, which closes it, and the trigger's own toggle, which reopens it — so the menu appears not to close. Treating the trigger as inside lets its own handler own the toggle. It is the same class of bug as the portal one: the component's interactive surface spans more than one element.
  • Why prefer pointerdown over click for the dismissal listener?
    A listener attached in an effect during the same click that opened the menu can catch the tail of that event and close it immediately. `pointerdown` fires earlier in the interaction, avoids that self-dismissal, and matches native menu feel. It also dismisses on drag starts, which users generally read as intent to leave the menu.
  • What else in a portalled overlay assumes DOM ancestry and quietly breaks?
    Focus containment — a focus trap or `document.activeElement` check must span the trigger and the portalled content; descendant CSS selectors and inherited custom properties; screen-reader reading order, which follows DOM order rather than visual order; and tests that query inside a rendered container instead of the whole document. Component libraries usually centralise all of this in one dismissable-layer primitive.

saying these in an interview costs you the question

  • Suggests raising z-index to fix the containment check
  • Thinks event.target is the portal container, not the clicked node
  • Says capture phase would make contains() report differently
  • Reaches for stopPropagation without noting it kills document listeners
  • Believes the portal changed which React handlers run

context