Ch. 3 · React

React Portals and Event Bubbling

Events from a React portal bubble through the React tree, not the DOM. Here is what that breaks in click-outside logic, and how to fix it.

~6 min readintermediateupdated Oct 19, 2026

Portals exist because CSS sometimes won’t let a child escape its parent. A dropdown menu inside a card with overflow: hidden gets clipped. A modal inside a container with transform or a low z-index stacking context can’t cover the page. createPortal(children, domNode) renders the children into a different DOM node, typically one at the end of <body>, while keeping them in the same place in the React component tree.

That split between “where it is in the DOM” and “where it is in React” is the whole topic. Context, state and props work as if the portal never moved. Events do too, and that’s the part that surprises people and breaks their code.

Two trees, one event

Here’s a card with an onClick handler. Inside it, a menu is portaled to #modal-root at the end of the body:

function Card() {
  return (
    <div id="card" onClick={(e) => console.log('card onClick, target:', e.target.id)}>
      <button id="in-card">In card</button>
      {createPortal(
        <div id="menu"><button id="menu-item">Delete</button></div>,
        document.getElementById('modal-root'),
      )}
    </div>
  );
}
JSX

In the DOM, #menu-item lives under #modal-root, nowhere near #card. Clicking it still runs the card’s onClick, with e.target being #menu-item. React’s synthetic events propagate along the React component tree, and in that tree the menu is a child of the card.

The React docs present this as a feature, and often it is: a parent can handle clicks or key presses from its own modal without caring where the modal’s DOM ended up. The bugs come from code that mixes the two trees.

A traced example: what each listener sees

To make the difference concrete, this component adds three kinds of listener to the card: a React onClick, a native addEventListener('click') on the card’s DOM node, and a document-level mousedown listener implementing “close when clicking outside” with cardRef.current.contains(e.target). Running it with React 19 in jsdom and clicking three buttons produced:

== click #in-card
React onMouseDown on card
document: click inside card
native listener on card div
React onClick on card (target #in-card, currentTarget #card)
== click #menu-item (in portal)
React onMouseDown on card
document: click OUTSIDE card -> would close
React onClick on card (target #menu-item, currentTarget #card)
== click #stopper (in portal, stopPropagation)
React onMouseDown on card
document: click OUTSIDE card -> would close
Text

Three lessons are in that log.

The native listener never fired for the portal click. Native DOM events bubble through DOM ancestors, and the card’s <div> isn’t one. Any code that attaches listeners with addEventListener (third-party libraries, analytics, older jQuery widgets) sees the portal as somewhere else entirely.

The document-level contains() check called the menu click “outside”. cardRef.current.contains(menuItem) is false, because contains checks DOM ancestry. If that handler closes the menu, the menu closes on mousedown before its own click can run, and “Delete” does nothing. This is the most common portal bug in dropdowns and popovers.

stopPropagation() in the portal stopped the card’s React onClick. The “Stop” button called e.stopPropagation(), and the card’s click handler didn’t run. The React onMouseDown on the card still ran, because that’s a different event. Calling stopPropagation inside a modal to “keep the click contained” can disable a handler on some distant React ancestor you didn’t know was there.

You may also notice the order on the first click: the native listener on the card ran before the React onClick on the same card. Since React 17, React attaches its listeners to the root container (and to each portal container), not to individual elements. A native listener on the card is lower in the DOM than the root, so it sees the bubbling event first.

Fixing click-outside for portaled menus

There are two reliable approaches.

Check every DOM region that belongs to the component. Keep a ref on the trigger and another on the portaled menu, and treat a click as inside if either contains the target:

const isInside = triggerRef.current?.contains(e.target) || menuRef.current?.contains(e.target);
JSX

This works but gets awkward when menus contain submenus or nested popovers, each with its own portal.

Let the React tree answer the question. Since React events bubble through the React tree, a handler on the component’s root sees pointer events from all its portals. Use it to set a flag that the document listener reads:

function useDismiss(onDismiss) {
  const insideTree = useRef(false);

  useEffect(() => {
    const onDown = () => {
      if (!insideTree.current) onDismiss();
      insideTree.current = false;
    };
    document.addEventListener('pointerdown', onDown);
    return () => document.removeEventListener('pointerdown', onDown);
  }, [onDismiss]);

  return { onPointerDown: () => { insideTree.current = true; } };
}

function ActionsMenu() {
  const [open, setOpen] = useState(false);
  const close = useCallback(() => setOpen(false), []);
  const dismissProps = useDismiss(close);

  return (
    <div {...dismissProps}>
      <button onClick={() => setOpen((o) => !o)}>Actions</button>
      {open && createPortal(<ul role="menu">…</ul>, document.body)}
    </div>
  );
}
JSX

The ordering makes this work. A pointerdown inside the menu bubbles to React’s listener on the portal container first, which runs onPointerDown and sets the flag. Then it keeps bubbling to document, where onDown sees the flag, skips dismissal and resets it. A pointerdown anywhere else never touches the React handler, so the flag is false and the menu closes. In a jsdom run of exactly this hook, a pointerdown on the portaled “Delete” item left the menu open, and one on a button outside the component closed it. Nested portals work automatically, because their events bubble through the same React ancestors. Wrap onDismiss in useCallback as shown, or the effect re-subscribes on every render.

Other places the two trees diverge

CSS. Styles that depend on DOM ancestry (.card .menu-item, inherited color, CSS variables set on the card) don’t reach portal content. Style the portal content on its own or set the variables on the portal container.

Focus and keyboard. A portaled dialog is at the end of the body in tab order. You have to move focus into it on open, trap Tab inside it, and return focus to the trigger on close. Native <dialog> with showModal() handles much of this for you and renders in the top layer without a portal.

Forms. A <button type="submit"> inside a portal doesn’t submit an outer <form> natively, because it isn’t a DOM descendant. But a submit event from a form inside the portal does bubble to an outer React onSubmit handler. If a modal with its own form sits inside a page form’s JSX, submitting the modal can trigger both handlers. Check e.target or stop propagation deliberately in that one place.

Testing. In Testing Library, portal content isn’t inside the component’s container. Query with screen (which searches document.body), not with queries scoped to container.

Practice question: the menu that won’t delete

“A table row has an actions dropdown rendered through a portal. Clicking ‘Delete’ in the dropdown closes it but never deletes. Clicking ‘Delete’ with the keyboard works. What’s happening?”

Answer. The keyboard path works because pressing Enter fires click without any mousedown, so whatever closes the menu isn’t triggered. The mouse path fires mousedown first, and the dropdown almost certainly has a document-level “click outside” listener on mousedown that checks rowRef.current.contains(e.target). Because the menu is portaled to the body, contains returns false, the listener closes the menu, the menu unmounts, and the click on “Delete” never reaches a mounted button. I’d fix it by making the outside check aware of the portal, either by checking the menu’s own ref as well, or by marking pointer events that pass through the component’s React tree and having the document listener skip those. I’d add a test that clicks the menu item with a pointer event sequence (pointerdown, mousedown, click), not just click, because a plain click() in a test would pass while users still saw the bug.

More in React

read ✓React · hard

React Context Splitting for Performance

Why every React context consumer re-renders when any part of the value changes, and how splitting state, actions and slow-changing data fixes it.

~6 min readread →
esc