A React component renders a modal with createPortal into document.body, yet clicks inside that modal still trigger an onClick handler on the component's own wrapper element. Why does the event reach a component that is not a DOM ancestor?
answer
- two parents: React and DOM
- propagation walks the JSX nesting
- native listeners see the real ancestors only
- the surprising half of portals
- document-level listeners still fire
basics
~20 sReact propagates its events along the React element tree, not the document tree. Portal children are still React children of the component that rendered them, so onClick handlers on React ancestors run even though the DOM nodes live under document.body.
solid answer
~50 sReact's event propagation is modelled on the React tree, not the DOM. When a click happens, React finds the component the target node belongs to and walks *up the React tree* collecting `onClick` handlers, so anything the portal is nested inside in JSX still fires — the portal changed the DOM position only. Native listeners behave the opposite way: an `addEventListener('click', …)` on the wrapper's DOM node never sees the click, because the portalled node is not its DOM descendant, while a listener on `document` does see it, since the portal container is inside the document. This is usually convenient — a modal's clicks reach the owner's handlers — but it also causes the surprise where a portalled dropdown's click triggers the parent card's "select row" handler. Calling `event.stopPropagation()` in a handler inside the portal stops that React-tree propagation.
code
javascript · 19 linesimport { useState } from 'react';
import { createPortal } from 'react-dom';
function Modal({ children }) {
return createPortal(<div className="modal">{children}</div>, document.body);
}
export default function Panel() {
const [clicks, setClicks] = useState(0);
return (
<div onClick={() => setClicks((c) => c + 1)}>
<p>Clicks counted by the wrapper: {clicks}</p>
<Modal>
<button>Inside the portal</button>
</Modal>
</div>
);
}go deeper
Remember the headline: clicks inside a portal still reach handlers on the components it is nested inside in JSX, because the portal moved only the DOM.
Explain the mechanism — React collects handlers by walking up the React tree from the target's component — and contrast it with a native listener, which only sees real DOM ancestors. Name a concrete bug it causes.
Show you can debug the surprise: a portalled menu firing a parent's row handler, or a hand-attached listener that mysteriously never fires. Discuss why stopPropagation is a patch with side effects and where you would restructure instead.
Own the composition risk: broad click handlers on containers that host portalled overlays make a component library unsafe to nest. Set the convention for where handlers may live and how overlay components document their propagation behaviour.
## Two propagation paths, and they disagree The answer only makes sense once you hold both trees in your head at the same time. Portalled content has a DOM parent (the portal container, often `document.body`) and a *different* React parent (whatever it is nested inside in JSX). React's events follow the second one. When a real click lands, React works out which component owns the DOM node that was hit, then walks upward through the React tree from that component, collecting the handlers of the matching prop — `onClick`, `onKeyDown`, and so on. Because a portal does not change where its children sit in the React tree, that upward walk continues straight into the component that called `createPortal`, and on into everything that component is nested inside. The handlers then run in the familiar order: capture-phase props (`onClickCapture`) top-down, bubble-phase props bottom-up. ```jsx function Panel() { return ( <div onClick={() => console.log('panel clicked')}> {createPortal(<button>Click me</button>, document.body)} </div> ); } ``` The button's DOM node is a child of `<body>`; `panel clicked` still logs. ## What the DOM does instead The native event knows nothing about React trees. It is dispatched on the real target and bubbles through the real DOM ancestors: the portal container, then `<body>`, `<html>`, `document`, `window`. So: - A native `addEventListener` on the wrapper `<div>` **does not fire**. That div is not a DOM ancestor of the portalled button. - A native listener on `document` or `window` **does fire**, because everything in the page ends up there. - `event.target` inside a React handler is the actual DOM element inside the portal, not a wrapper object — React 17 removed event pooling, so the synthetic event is a plain object you can hold onto and read asynchronously. This split is precisely why mixing React handlers with hand-attached listeners around portals is fragile: the two see different ancestries. ## The bug this causes The convenience side is real — a portalled modal can call handlers that live in the owning component without prop drilling, and context-based overlay systems rely on it. The bug side is equally real. A table row with `onClick={selectRow}` renders a portalled action menu; every click on a menu item now also selects the row, and the DOM inspector gives you no hint why, because in the DOM the menu is nowhere near the row. The direct fix is to stop propagation at the top of the portal content: ```jsx {createPortal( <div className="menu" onClick={(e) => e.stopPropagation()}>…</div>, document.body, )} ``` That stops React from continuing the walk to ancestor components. Be aware it also stops the underlying native event, so a `document`-level listener further out may stop seeing the click too — which is either the fix you wanted or an unrelated feature you just broke. The more robust structural fix is not to put a broad click handler on a container that owns portalled overlays in the first place: attach `selectRow` to the row's own content, not to a wrapper whose React subtree includes the menu. ## Where React actually listens One implementation detail is worth knowing because it explains why native and React handlers interleave oddly. React does not attach a listener to each element; it attaches listeners at the root container you passed to `createRoot`, and wires up portal containers as well, then dispatches synthetically from there. So by the time your `document`-level native listener runs, React has already dispatched the whole synthetic chain — which is also why `stopPropagation()` inside a React handler can prevent a `document` listener from ever running. ## Answering it well Say the rule first — React events follow the React tree, native events follow the DOM tree — then show you know the consequences in both directions: the parent handler that unexpectedly fires, and the native listener on the parent that unexpectedly does not. Candidates who claim portals "break bubbling" or that portalled content is isolated from its parent have inverted the model.
- Would a native addEventListener on that wrapper div also fire for the portalled click?No. Native events bubble through real DOM ancestors only, and the portalled node is a child of `document.body`, not of the wrapper. The wrapper's own listener never sees it, while a listener on `document` or `window` does. That asymmetry — React handler fires, native handler on the same element does not — is the clearest demonstration that the two propagation paths differ.
- What does calling event.stopPropagation() inside a portalled handler actually stop?It stops React from continuing up the React tree, so ancestor components' handlers do not run. It also calls stopPropagation on the underlying native event, so listeners attached further out in the DOM — typically on `document` — may stop firing as well. That side effect is easy to miss and often breaks an unrelated outside-click or analytics listener, so prefer restructuring where the broad handler lives.
- How would you stop a portalled dropdown from triggering the row-selection handler it is nested inside?Either stop propagation at the root of the portal content, or, better, move the row-selection handler off the wrapper that contains the portal and onto the row's actual content. The second is structural: it removes the accidental React-tree ancestry instead of patching each event. Relying on stopPropagation everywhere makes the component fragile for anyone composing it later.
saying these in an interview costs you the question
- Says portals break or disable event bubbling
- Thinks a native listener on the parent element also fires
- Claims portalled content is isolated from parent handlers
- Says event.target is a React wrapper, not a DOM node
- Believes portal content needs its own event system