skip to content

A modal rendered through createPortal into document.body still triggers the onClick of the React component that rendered it, even though the modal's DOM node is not inside that component's DOM. Why does React dispatch it that way, and what does it break?

level: middleimportance: should knowfreq 50%

answer

  1. two trees disagree, one wins
  2. dispatch walks fibers, not DOM nodes
  3. portal parent is who rendered it
  4. contains() tells the DOM truth
  5. check both refs, not one

basics

~20 s

React propagates events along the component tree, not the DOM tree. It registers listeners on the portal's container too, then walks the portal content's React parent chain — which still includes the component that rendered it — so ancestors receive the event.

solid answer

~40 s

React's event propagation follows the React tree, because dispatch is a replay React performs, not real DOM bubbling. When an event fires inside portal content, React picks it up (it registers its delegated listeners on the portal container as well as the root), finds the fiber for the target, and walks that fiber's parent chain — and a portal's fiber parent is the component that rendered it, wherever its DOM ended up. So `onClick` on the rendering ancestor fires normally, which is usually what you want: a portal is a DOM escape hatch, not a component-tree escape hatch. What it breaks is anything reasoning in DOM terms. The classic case is outside-click detection: `containerRef.current.contains(event.target)` is `false` for a click inside the portal, so a dropdown closes its own menu the moment you click it.

code

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

export function Dropdown() {
  const [open, setOpen] = useState(false);
  const anchorRef = useRef(null);
  const menuRef = useRef(null);

  useEffect(() => {
    if (!open) return;
    const onDocClick = (event) => {
      const inside =
        anchorRef.current?.contains(event.target) ||
        menuRef.current?.contains(event.target);
      if (!inside) setOpen(false);
    };
    document.addEventListener('click', onDocClick);
    return () => document.removeEventListener('click', onDocClick);
  }, [open]);

  return (
    <div ref={anchorRef} onClick={() => console.log('ancestor sees portal clicks too')}>
      <button onClick={() => setOpen((v) => !v)}>Menu</button>
      {open && createPortal(<ul ref={menuRef}><li>Item</li></ul>, document.body)}
    </div>
  );
}

go deeper

for a junior

Remember the headline: a portal moves the DOM node, not the component. Events still reach the React parent, so an ancestor's onClick fires for clicks inside a portal-ed modal.

for a middle

Explain the mechanism — React dispatches by walking the fiber parent chain, and it registers listeners on the portal container so the event is picked up in the first place — and name the containment-check bug that follows.

for a senior

Show the debugging instinct: when a dropdown closes itself or a card handler fires from inside a dialog, identify whether the code is reasoning in DOM space or React space, and fix it with explicit refs rather than blanket stopPropagation.

for a principal

Own the convention: where portal roots live, who is allowed to stop propagation at a portal boundary, and how shared overlay components expose the refs that containment checks and focus traps need, so every team is not rediscovering this bug.

## Two trees, and events follow the wrong one on purpose A portal deliberately splits the two trees apart. In the React tree, the modal is a child of the component that rendered it. In the DOM, it is a child of `document.body`, far away. Event propagation has to pick one, and React picks the React tree. That is a design decision, and a defensible one. You reach for a portal for *layout* reasons — escaping `overflow: hidden`, `z-index` stacking, or a clipping ancestor. You did not intend to change what the component is a part of. Keeping events on the React tree means a portal is transparent to everything except CSS: context still reaches it, error boundaries still catch it, and event handlers on ancestors still fire. ## The mechanism React's dispatch does not depend on DOM bubbling beyond one step. It needs the event to arrive at a listener React installed, and then it takes over: 1. React registers its delegated listeners on the **portal's container element** as well as on the root container. Without that, an event originating in `document.body` would never reach React at all, since `body` is outside the root. 2. When the listener fires, React finds the fiber for `event.target`. 3. React walks that fiber's **return path** — the component-tree parent chain — collecting `onClickCapture` props downward and `onClick` props upward. 4. A portal fiber's parent is the component that rendered the portal. So the walk continues straight into the "outer" tree. Meanwhile the browser is doing its own thing: the native event also bubbles up the real DOM path (`body` → `html` → `document`), so native listeners on those nodes see it too. Both propagations happen; they just take different routes. ## What this breaks **Outside-click detection.** The overwhelmingly common bug. A dropdown listens on `document` and closes when the click is not inside its container: ```javascript if (!containerRef.current.contains(event.target)) setOpen(false); ``` Portal the menu into `document.body`, and `contains` is now `false` for clicks on the menu itself, because that is the literal DOM truth. The menu closes when you click an item in it. The fix is to check every relevant DOM subtree — the trigger *and* the portal content — rather than assuming one node covers the component. **Unexpected ancestor handlers.** A card with `onClick={openDetails}` that also renders a portal-ed confirm dialog will open the details view when someone clicks inside the dialog. Nothing is broken in React's terms; the dialog really is inside the card, in the tree that governs events. Either stop propagation at the portal's root element, or — better — do not put a broad `onClick` on a container that also hosts portals. **Third-party libraries reasoning about the DOM.** Anything doing hit-testing, focus trapping, or `closest()` from the event target is working in DOM space and will disagree with React's dispatch. Give it explicit node references instead of inferring structure. ## The fix, concretely ```javascript import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; function Dropdown() { const [open, setOpen] = useState(false); const anchorRef = useRef(null); const menuRef = useRef(null); useEffect(() => { if (!open) return; const onDocClick = (event) => { const inside = anchorRef.current?.contains(event.target) || menuRef.current?.contains(event.target); if (!inside) setOpen(false); }; document.addEventListener('click', onDocClick); return () => document.removeEventListener('click', onDocClick); }, [open]); return ( <div ref={anchorRef}> <button onClick={() => setOpen((v) => !v)}>Menu</button> {open && createPortal(<ul ref={menuRef}><li>Item</li></ul>, document.body)} </div> ); } ``` Note the second guard: without `menuRef`, this component closes its own menu. Note also that a `stopPropagation()` inside the menu would be a *different* fix with different collateral — it would silence the React ancestors too, and stop the event reaching `document` at all. ## Deciding which behaviour you want - **Want ancestors to see it** (a form wrapping a portal-ed field, a card-level analytics handler that should count dialog interactions): do nothing. This is the default. - **Want the portal isolated**: attach handlers to the portal's own root element and stop propagation there, deliberately, with a comment saying why. - **Need DOM-space truth** (containment, focus traps, hit tests): hold refs to every node you care about and test them all. Never infer the component's DOM footprint from a single ref when a portal is in play. ## Saying it in an interview "React events propagate along the React tree, not the DOM tree, because dispatch is a walk over the fiber parent chain — and React registers its listeners on the portal container so it still sees the event. The practical consequence is that DOM containment checks lie: outside-click detection must test the portal node too, or the dropdown closes itself."

  • If the portal's DOM node is outside the root container, how does React see the event at all?
    React registers its delegated listeners on the portal's container element too, not only on the root container. That gives dispatch a door for events originating in portal content. Once the listener fires, React proceeds normally — fiber lookup for the target, then a walk up the fiber parent chain, which leads back into the main tree.
  • How would you deliberately stop a portal's clicks from reaching the component that rendered it?
    Put a handler on the portal content's own root element and call `stopPropagation()` there. That ends React's walk before it reaches the ancestors, and — because React's underlying listener is on the portal container — it also stops the native event continuing up to `document`. Do it explicitly with a comment; it is a broad claim and it will surprise the next reader.
  • Does context still reach a component rendered through a portal?
    Yes. Portals only relocate DOM output; everything defined by the component tree still applies, so providers above the portal are visible inside it, error boundaries above it still catch its errors, and state ownership is unchanged. That consistency is exactly why events follow the React tree too — a portal is a layout escape hatch, not a tree escape hatch.

saying these in an interview costs you the question

  • Says portal events bubble through the DOM to document.body's ancestors only
  • Thinks a portal detaches the component from its React parent
  • Fixes outside-click detection by adding stopPropagation everywhere
  • Assumes ref.current.contains covers all of a component's DOM
  • Believes React has to re-implement bubbling because portals broke it

context