There’s a React behaviour which goes against our mental model for events. You write a React app, and somewhere outside it in the plain DOM, you add an event listener directly on the document using document.addEventListener('click', handler).
Imagine in one of the components in your app, a button registers the onClick event handler, where the handler called e.stopPropagation(). Our mental model suggests that on the click of the button, the propagation will stop bubbling the event any further up the tree, thereby never reaching the handler on the document.
However, in React 16 the click handler on the document triggers anyway despite the button click handler calling e.stopPropagation(), because by the time the React handler runs, there is no bubble left to stop.
The reason is a single design decision in how React wires itself to the browser, and React 17 changed that decision.
The decision was to move a single address: the DOM node that React attaches its real listeners to, and once the node was moved, the meaning of stopPropagation moved with it, even though the function being called is exactly the same.
So it boils down to one question: Where does React actually listen?
The naive model, and why it is wrong
If you write <button onClick={...}>, you might naturally assume that React calls button.addEventListener('click',...) for you, because the props reads it like: onClick looks like a handler attached straight onto that one button, the way you would register in plan DOM. That is the naive model, both because the API invites it and because it is the quiet assumption underneath the puzzle we opened with.
Keeping that model in mind, a document listener that keeps firing after our handler already called e.stopPropagation() looks like it breaks the rules of how events travel.
The sense in which the model is wrong is that: React does not attach a native click listener to the button. It relies on event delegation instead, placing a small number of native listeners high up in the tree and letting the browser’s own bubbling carry the click up to one of them, at which point it works out which component you actually meant and calls our handler.
This is how you can have thousands of onClick handlers in a page without thousands of native listeners behind them.
React instead adds about one delegated listener per event type, all of them in a single place, and leaves the button itself bare while the real listener sits somewhere above it.
I’m not taking a dig by calling this mental model naive. It is a reasonable guess that happens to have a gap, and this gap is the point I’m digging into.
If React really did listen on the button, then e.stopPropagation() in our handler would run at the very bottom of the bubble and quiet every ancestor above it, the document included, exactly the way our intuition says it should. As per our naive model, there is no surprising behaviour at all. It happens only because the listener is not on the button but somewhere above it, so the question reduces to how far above? That is why where React listens decides everything, and in React 16 the answer was the document.
How React 16 found the document
In React 16 the attach happens lazily when React builds the DOM node for a component that uses a given event handler. When React sets up an element’s props, it calls ensureListeningTo, and that function does something quietly consequential (v16.13.1/packages/react-dom/src/client/ReactDOMComponent.js#L262-L273):
function ensureListeningTo(rootContainerElement, registrationName) { const isDocumentOrFragment = rootContainerElement.nodeType === DOCUMENT_NODE || rootContainerElement.nodeType === DOCUMENT_FRAGMENT_NODE; const doc = isDocumentOrFragment ? rootContainerElement : rootContainerElement.ownerDocument; legacyListenToEvent(registrationName, doc);}Take a look at what gets passed down as the mount target: it is doc, the container’s ownerDocument and not the app container element. Just to emphasize, it is certainly not the button you attached the handler to. From there legacyListenToEvent walks what React calls the event’s dependencies. A React handler name does not line up one-to-one with a browser event, it maps to the set of native browser events it needs, so onClick depends on just the native click, while onChange quietly depends on eight of them (blur, change, click, focus, input, keydown, keyup, and selectionchange), because React reconstructs one tidy change event out of all of those.
Each of those native types is a top-level type, meaning one raw browser event that React traps at the top of the tree rather than on the element itself, and legacyListenToTopLevelEvent traps each one onto that document node through trapBubbledEvent and trapCapturedEvent (v16.13.1/packages/react-dom/src/events/DOMLegacyEventPluginSystem.js#L323-L365, with the trap helpers at v16.13.1/packages/react-dom/src/events/ReactDOMEventListener.js#L81-L93), so that every delegated React event anywhere in the page, no matter which root or which subtree it came from, funnels into a handful of listeners sitting on the document.
Once we notice this, the behaviour stops surprising. A native click begins at the button and bubbles the normal DOM way, climbing up through every ancestor and firing any native listener attached along the route, all the way up to the document, and only at that final stop does React’s own listener run and begin simulating its capture-and-bubble pass through the fiber tree. Which means any plain DOM listener sitting between our button and the document has already fired, before React has done a single thing. So when our React handler finally calls e.stopPropagation(), that call does real work. The e you are holding is React’s SyntheticEvent, a wrapper around the real browser event, and its stopPropagation is not a decorative no-op: it forwards straight down to the browser event underneath, calling nativeEvent.stopPropagation() on it (react-16 v16.13.1/packages/legacy-events/SyntheticEvent.js#L129-L147):
stopPropagation: function() { const event = this.nativeEvent; if (!event) { return; } if (event.stopPropagation) { event.stopPropagation(); } ... this.isPropagationStopped = functionThatReturnsTrue;}So, the issue is not that the stop fails. It is working exactly as written. The core issue is that it runs too late, because by the time it runs the native event is already up at the document, at the top of the bubble, and calling stopPropagation there stops the event from propagating to nothing: there is nothing left above it to visit, and everything below it has already fired on the way up. React’s stop is neither broken nor a no-op, it is simply being called at the last possible point, after the native event has finished travelling. That lateness is the whole bug, and it follows directly from one fact, that the listeners live on the document.
The move: from the document to the root container
React 17 introduced a simple change, and that is to move the address of event listeners. Instead of the document, the delegated listeners now attach to the root DOM container you hand to render or createRoot. React 17 also changed the timing. Where React 16 was lazy, adding the listener for a given event type only once some component that used it first showed up, React 17 is eager: the moment you create a root, it attaches listeners for every event type it supports up front, before a single component has rendered (v17.0.2/packages/react-dom/src/events/DOMPluginEventSystem.js#L322-L349):
export function listenToAllSupportedEvents(rootContainerElement) { if (enableEagerRootListeners) { if (rootContainerElement[listeningMarker]) { return; } rootContainerElement[listeningMarker] = true; allNativeEvents.forEach(domEventName => { if (!nonDelegatedEvents.has(domEventName)) { listenToNativeEvent(domEventName, false, rootContainerElement, null); } listenToNativeEvent(domEventName, true, rootContainerElement, null); }); }}That rootContainerElement is our app’s mount node, and this runs once per root out of createRootImpl (v17.0.2/packages/react-dom/src/client/ReactDOMRoot.js#L120-L169). One detail that’s easy to miss, because the comment at the top of createRootImpl reads “Tag is either LegacyRoot or Concurrent Root,” which means the old ReactDOM.render path and the new createRoot path both flow through here, so the delegation move applies to legacy apps too and not only to anyone opting into the new root API.
The consequence is the exact mirror image of the React 16 situation. A native click now bubbles up from your button only as far as the root container, where React’s listener is waiting to catch it, and if one of your handlers calls e.stopPropagation() the very same nativeEvent.stopPropagation() runs (v17.0.2/packages/react-dom/src/events/SyntheticEvent.js#L105), except that this time the native event is standing at your container, quite possibly deep inside the page, with the document still sitting above it.
Because there is still tree left above the interception point, the stop now actually stops something: the event never climbs past the container, and that document listener you added outside the app, the one that fired in React 16 no matter what you did, finally stays quiet. It is the same synthetic method calling the same native function to the opposite effect, and the only thing that changed is that the interception point dropped down out of the document and into your own subtree.
The same change also isolates nested React trees from each other. If one root is mounted inside the DOM of another, each has its own container listener, so stopPropagation in the inner tree halts the native event before it can bubble up to the outer tree’s container and trigger the outer root’s handlers, whereas in React 16 that isolation was impossible, because both roots shared the same listeners on the same document and there was simply no lower point at which the inner tree could cut the event off.
Why give up the document at all
So why would React give up a node that convenient? The document did have one honest advantage, which is that it is the single node guaranteed to exist and to sit above everything else. One set of listeners placed there catches every event on the page without React ever needing to know where its own trees actually live and this detail matters. Once the listeners move off the document and onto specific containers, React can only catch an event if it has already put a listener on some node above where that event started, which means it now has to know every place it mounts DOM, every root and every portal. Miss one, and events originating inside that spot would never reach a React listener at all, so a handler there would simply never fire.
That is exactly why in React 17 you find listenToAllSupportedEvents being called a second time for portal containers, because a portal renders into DOM outside its root’s container and would otherwise have no React listener anywhere above it. That is bookkeeping the document approach never had to do.
What React trades for is coexistence. When React owns the document, two React trees on the same page are really sharing one global interception point and there is no way to make them independent, no way to let one tree’s events stop cleanly without leaking into the other. Whereas attaching per container gives each tree its own boundary. That boundary is exactly what we need in order to embed a React app inside a larger app we do not control. This allows us to run two React versions side by side during a slow migration, or to let non-React code above our root see the events our handlers chose to stop.
So, the behavior change that replaced the old document-listener trick and the isolation that makes gradual adoption safe are not two separate changes: they are two results of one change, moving the interception point down from the document into your container. The moment React stops at our container instead of the document, stopPropagation starts to take effect for that outer document listener and separate trees stop leaking into each other, both at the same time. We cannot keep the old stopping behavior and also gain the isolation, because they are the same move seen from two directions.
The smaller moves around the big one
React 16 listened both lazily and narrowly in the sense that it trapped only the event types our components actually used, and trapped each of them only the first time React built the DOM for a component using that handler.
Whereas React 17 flipped both of those choices at once: to eager and total so that at the root creation it walks allNativeEvents and attaches every one of them up front, minus the handful in nonDelegatedEvents, such as scroll and the media events, which React binds on their own targets instead.
This eager path shipped behind a feature flag that was already switched on, enableEagerRootListeners = true, which is a hint that React 17 briefly carried both strategies in its source and simply chose the eager one as the default. By React 18 the flag is gone from the source entirely. Container delegation is the default, and the old lazy document-listening code went out with it.
Since then, the listener target has held that position. React 18 attaches to the container out of the same listenToAllSupportedEvents (react-18), called from v18.3.1/packages/react-dom/src/client/ReactDOMRoot.js#L240 and v18.3.1/packages/react-dom/src/client/ReactDOMRoot.js#L318, and React 19 does the identical thing after relocating the events code into a new package, with listenToAllSupportedEvents now living at v19.2.7/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L432-L458 and driven from v19.2.7/packages/react-dom/src/client/ReactDOMRoot.js#L256.
There was one more change under the same scope that was part of the same release: the pooling change. React 16 did not hand you a fresh event object for every event. To save the cost of creating and garbage-collecting objects on a busy page, it kept a small inventory of reusable SyntheticEvent objects, at most ten of them. A pattern usually called an object pool or a free list, and drew from that stash on each event.
The catch was what happened when our handler returned: React wiped the event clean, setting every one of its fields back to null, and returned it back in the stash to be refilled and handed out for the next event. This recycling is why reading e asynchronously, after our event handler had already finished, gave us a blanked-out object. And this is where the old e.persist() advice comes from, since calling persist() was how you told React to pull that one event out of the pool and leave it alone.
React 17 deleted pooling altogether and turned persist() into a no-op whose entire body is the comment “Modern event system doesn’t use pooling”. The two landed in the same release but they answer separate questions: pooling governs the lifetime of the event object after your handler runs, while delegation governs where the event was intercepted before your handler ran.
What the whole thing is really about
From my perspective the learning is big. React’s event system is not the browser’s event system. It is kind of a simulation of one which is layered on top that reconstructs the capture and bubble by walking the fibre tree rather than the native DOM. A simulation like that has to plug into the real DOM somewhere, and the node it plugs into decides how everything above it behaves.
Register at the document and stopPropagation becomes a promise React cannot fully keep, because by the time React’s listener runs the real event has already finished bubbling up the tree.
Register it down into the app container, and the promise becomes real at the cost of React needing to know about every root and every portal.
One key bit to acknowledge is that nothing about the public API changed through any of this. onClick is still onClick, and e.stopPropagation() is still the very same method calling the very same native function it always did. The behavior moved because one internal address moved, which is a good reminder that in a system built in this many layers, the decisions that actually change how things behave are rarely in the API you call, and are usually hidden in where that API connects to the thing underneath it.
Where this lives in the source
- react-16 attaches to the document:
- v16.13.1/packages/react-dom/src/client/ReactDOMComponent.js#L262-L281 (
ensureListeningToresolvesownerDocument), - v16.13.1/packages/react-dom/src/events/DOMLegacyEventPluginSystem.js#L323-L365 (
legacyListenToTopLevelEvent), - v16.13.1/packages/react-dom/src/events/ReactDOMEventListener.js#L81-L93 (
trapBubbledEvent/trapCapturedEvent).
- v16.13.1/packages/react-dom/src/client/ReactDOMComponent.js#L262-L281 (
- react-16 synthetic
stopPropagationcalls the native event: - react-17 attaches to the root container:
- v17.0.2/packages/react-dom/src/events/DOMPluginEventSystem.js#L322-L349
- (
listenToAllSupportedEvents), called from v17.0.2/packages/react-dom/src/client/ReactDOMRoot.js#L141 (both legacy and concurrent roots), - plus per-portal attach at v17.0.2/packages/react-dom/src/client/ReactDOMHostConfig.js#L1077.
- react-17 eager-listening flag on by default: v17.0.2/packages/shared/ReactFeatureFlags.js#L136 (
enableEagerRootListeners = true). - react-17
- synthetic
stopPropagationcalls the native event: v17.0.2/packages/react-dom/src/events/SyntheticEvent.js#L105; persistno-op (“Modern event system doesn’t use pooling”) at v17.0.2/packages/react-dom/src/events/SyntheticEvent.js#L125,isPersistenthardwired true at v17.0.2/packages/react-dom/src/events/SyntheticEvent.js#L133
- synthetic
- react-18 keeps container delegation: v18.3.1/packages/react-dom/src/events/DOMPluginEventSystem.js#L386-L412 called from v18.3.1/packages/react-dom/src/client/ReactDOMRoot.js#L240.
- react-19 keeps container delegation after the package move: v19.2.7/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L432-L458, called from v19.2.7/packages/react-dom/src/client/ReactDOMRoot.js#L256 and v19.2.7/packages/react-dom/src/client/ReactDOMRoot.js#L354, with per-portal attach at v19.2.7/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L763.