React 17 moved React's delegated event listeners off document and onto the container element passed into the root API. Which real problems did that change fix, and what does it mean for a page running more than one React root?
answer
- depth in the tree decides ordering
- React used to sit above everyone
- both directions of stopPropagation were wrong
- driven by gradual-upgrade needs
- two roots can now nest cleanly
basics
~20 sAttaching at the root container makes React a normal participant in DOM propagation: stopPropagation in a React handler now really stops document-level listeners, non-React code between the root and document no longer silences React, and independent roots on one page stop interfering with each other.
solid answer
~40 sBefore React 17, React attached its listeners to `document`, which put it above every other listener on the page and produced two mirror-image bugs. Non-React code on an element between your root and `document` could call `stopPropagation()` and React handlers would still fire — because React was listening even higher up. And calling `e.stopPropagation()` inside a React handler did *not* stop listeners registered on `document`, because React's own dispatch was already running at that level. Moving to the container makes React sit at its natural depth in the tree, so both behave as you would expect. The motivating case was gradual upgrades: two independently versioned React roots on one page can now nest, each owning its own subtree, with the inner root's `stopPropagation()` correctly preventing the outer root's handlers from running.
go deeper
Know the headline fact: React's listeners live on the root container you pass to createRoot, not on document and not on individual elements. That is enough at this level.
Explain why the position matters — a listener's depth decides when it runs relative to everyone else's — and give both symmetric bugs the old design caused with stopPropagation.
Reason about it during interop debugging: check whether a native listener between the target and the root container is stopping the event, and know that React 17 only fixed interference from above the root, never from inside it.
Own the migration angle: this is what makes incremental adoption of a new React version feasible on a large page, with independently versioned roots nesting predictably. Be ready to weigh that against running one root and shipping one version.
## What actually changed React has always used delegation — a small number of real DOM listeners, from which it dispatches to your props. What changed in React 17 is *where* those listeners sit. Before: `document`. Since: the container element you hand to the root API (`createRoot(container)` / `hydrateRoot(container, ui)`). React 19 keeps this design. That sounds like an implementation detail. It is not, because the position of a listener in the DOM tree determines when it runs relative to everyone else's listeners. ## Bug one: other people's stopPropagation silenced React Imagine a page where a jQuery-era widget, an analytics script, or a hand-written listener sits on an element between your React root and `document`, and calls `stopPropagation()` on click. - **Listening on `document`:** the event is stopped before it ever reaches `document`, so React's single listener never fires — and *no* React `onClick` anywhere in the app runs for that click. From inside a React component this looks like the framework silently broke. - **Listening on the container:** React's listener is *below* the offending node, so it has already run by the time propagation is stopped. Your handlers fire normally; only listeners above the container miss out. React degrades exactly like any other listener at that depth. ## Bug two: React's own stopPropagation did not stop anything The mirror image. You call `e.stopPropagation()` in a React `onClick`, expecting a global `document`-level listener not to see the click. - **Listening on `document`:** React's dispatch was itself happening at the document level, so by the time your handler ran, the event had already arrived where those listeners live. Stopping propagation there could not un-ring that bell reliably. - **Listening on the container:** stopping propagation in a React handler now genuinely prevents the event from continuing up past the container, so `document`-level listeners do not run. This is the behaviour people assumed they already had. The practical version of this is the outside-click dropdown: `document.addEventListener('click', close)` plus a `stopPropagation()` somewhere inside the menu. Since React 17 that combination actually works as written — and, when you did not intend it, actually breaks the outside-click detector. Either way, the behaviour is now predictable from ordinary DOM rules. ## The motivating case: more than one root on a page The change was driven by gradual upgrades. A large app may want to run two versions of React side by side, or embed a widget built with a different React version, and migrate screen by screen. With everything delegating to `document`, the roots were fighting over one dispatch point, and there was no sane way to define what `stopPropagation()` in the inner tree meant for the outer tree. With container attachment, each root is a normal node in the DOM: ```javascript createRoot(document.getElementById('legacy-shell')).render(<Shell />); // somewhere inside Shell's DOM output: createRoot(document.getElementById('new-widget')).render(<Widget />); ``` A click inside `#new-widget` hits the inner container's listener first and is dispatched through the inner React tree; if a handler there stops propagation, the event never reaches `#legacy-shell`, so the outer root never dispatches it. Nesting composes the way DOM nesting composes, with no special cases. ## What else React 17 aligned at the same time - `onScroll` no longer bubbles through the React tree, matching the DOM, so parents stop receiving every descendant's scroll events. - `onFocus` and `onBlur` are implemented on top of the native `focusin`/`focusout` events, which do propagate — making React's focus events behave consistently with the DOM they model. - Capture-phase React props are registered using real browser capture on the container rather than being simulated. - Event pooling was removed, so a synthetic event's fields remain readable after the handler returns. ## What it does not change The dispatch model is untouched: React still finds the fiber for `event.target`, walks the component tree, and calls the props it collects. Portals still propagate along the React tree rather than the DOM tree, and React registers listeners on portal containers so events originating there are still captured. And the fundamental interop rule still holds — **anything that stops propagation between the event target and the root container prevents React from dispatching at all.** React 17 moved the door; it did not add a second one. ## Saying it in an interview "Since React 17 the delegated listeners live on the root container, not document. That makes React an ordinary participant in propagation: stopPropagation in a React handler now really stops document-level listeners, and non-React code above the root no longer silences the whole app. The reason was gradual upgrades — multiple roots on one page can nest without stepping on each other."
- If React now listens on the container, what happens when non-React code stops propagation on a node inside the container?React is silenced for that event. The listener is on the container, so anything that stops the event below it — between the target and the container — means React never dispatches, and no handler in the app runs for that click. React 17 fixed interference from *above* the root; interference from *inside* the root is still fatal and is the interop bug to look for first.
- Besides moving the listeners, what other event-system changes shipped in React 17?`onScroll` stopped bubbling through the React tree, matching the DOM; `onFocus` and `onBlur` were reimplemented on the native `focusin`/`focusout` events so they propagate consistently; capture-phase props began using real browser capture on the container; and event pooling was removed, so synthetic event fields stay readable after the handler returns.
- Does a portal rendered outside the root container still receive React events after this change?Yes. React registers its delegated listeners on the portal's container element too, so an event originating inside portal content is still picked up and dispatched. React then propagates it along the React component tree, which means the component that rendered the portal sees it even though its DOM node is not a DOM ancestor.
saying these in an interview costs you the question
- Says React 19 still attaches all its listeners to document
- Thinks the change was purely a performance optimization
- Claims stopPropagation in React never affects native listeners
- Believes multiple React roots on one page are unsupported
- Confuses removing event pooling with moving the listeners