In-Depth Article
React Synthetic Event System: How Delegation, Propagation, and Plugins Work
Understand how React's event system really works: SyntheticEvent wrapping, root-level delegation, Fiber-based listener collection, dispatch queues, and plugin-based event extraction.
React's event system is much more than "React calls addEventListener for you." Under the hood, React DOM runs a full event pipeline that:
- attaches most native listeners once per root container
- converts native events into
SyntheticEventobjects - finds the closest Fiber for the target DOM node
- collects React listeners along the Fiber path
- simulates capture and bubble in a controlled dispatch queue
That is why React events feel like normal DOM events from the component side, while still giving React room to normalize behavior and manage large trees efficiently.
If you want nearby context first, this article pairs well with how JSX becomes DOM in React, how React diffing works in ReactChildFiber, and how React turns state updates into a render and commit.
1. Why React built its own event system
Historically, React's event system solved two major problems:
- browser inconsistencies in event APIs and behavior
- the cost of attaching listeners all over a large DOM tree
The compatibility story was especially important when older browsers behaved very differently. Modern browsers are much closer today, but React still keeps the abstraction because it also gives React:
- a consistent event interface
- centralized delegation
- plugin-based normalization for tricky event types
- tight integration with the Fiber tree instead of raw DOM alone
So the right mental model is:
React events are a DOM-backed abstraction layer, not a separate browser event system.
2. The core object: SyntheticEvent
When you write this:
<button onClick={(e) => console.log(e.type)} />
the e you receive is usually a SyntheticEvent, not the raw browser event object.
At a high level, a synthetic event wraps the native event and exposes a normalized interface:
const syntheticEvent = {
nativeEvent,
target,
currentTarget,
type,
preventDefault() {},
stopPropagation() {},
};
The current source in SyntheticEvent.js shows a few important details:
nativeEventpoints to the original browser eventtargetis derived from the native targetcurrentTargetstarts asnulland is assigned while each listener runspreventDefault()forwards to the native event and updates React's own flagstopPropagation()also forwards to the native event and marks propagation as stopped inside React
That last part matters:
SyntheticEvent.stopPropagation()affects both native bubbling and React's own dispatch queue.
3. Event pooling is historical now, not current behavior
One of the most confusing old React event quirks was event pooling.
In React 16 and earlier, SyntheticEvent objects were pooled and reused. After the event handler finished, React would clear fields on the object. That is why older code sometimes needed e.persist() before using the event inside setTimeout or another async callback.
As of React 17, that behavior is gone.
The legacy docs explicitly say e.persist() no longer does anything in v17+, and the current SyntheticEvent.js implementation keeps persist() as a no-op with isPersistent() always returning true.
So today the simpler rule is:
- event objects are no longer pooled
- you can generally read event fields later without the old pooling trap
One small nuance still matters:
currentTargetis only meaningful during listener execution because React assigns it per dispatch and then resets it tonull.
So even without pooling, e.currentTarget is still not a "store forever" value.
4. Delegation: React usually listens at the root, not on every node
Modern React does not usually attach a separate native listener to every rendered DOM node.
Instead, when a root is created, React calls:
listenToAllSupportedEvents(rootContainerElement);
You can see that in ReactDOMRoot.js.
And inside DOMPluginEventSystem.js, listenToAllSupportedEvents(...) loops over supported native events and installs listeners on the root container.
That means the typical model is:
- one root container
- one native listener per event type per phase
- many component-level handlers resolved later through Fiber traversal
This is classic event delegation, but implemented inside React DOM's own event system.
5. Not every event is delegated the same way
It is useful to soften one common simplification:
React delegates most events, not literally every event in exactly the same way.
The current source keeps a nonDelegatedEvents set for special cases such as:
scrollloadinvalidtoggle- several media-related events
There is also one special case worth calling out:
selectionchangeis attached to the owningdocument, not the root container
So the accurate rule is:
- most events are delegated at the root
- some events are handled specially because their native behavior does not fit the default delegation path
6. React 16 vs React 17+: where the delegated listeners live
This version difference is one of the most important parts of the story.
React 16 and earlier
For most events, React attached delegated listeners at the document level.
React 17 and later
The React 17 RC blog announced a key change:
React no longer attaches most event handlers at
document. It attaches them to the root DOM container instead.
That solved a real integration problem. With document-level delegation, nested React trees or mixed frameworks could produce surprising propagation behavior because every React version was listening at the top.
Root-level delegation improves isolation:
- nested or separate React roots interfere less
stopPropagation()behaves more like normal DOM expectations across root boundaries- embedding React into larger non-React apps becomes less fragile
7. The runtime flow: what happens when you click a React button
This is the most useful mental model to keep.
Suppose the user clicks a button inside a React root.
Step 1: the browser fires a native event
The browser dispatches the real DOM click event as usual.
Step 2: React's delegated native listener receives it
Because React already attached the root listener during mount, the native event eventually reaches React's event entry point for that root.
Step 3: React maps the target DOM node back to a Fiber
React needs to know which component tree location this DOM node belongs to.
The current DOM binding code in ReactDOMComponentTree.js stores an internal key on host nodes with a name like:
__reactFiber$<random>
React then uses helpers like getClosestInstanceFromNode(...) to recover the nearest Fiber instance from the native target node.
That is the bridge between:
- browser DOM nodes
- React Fiber instances
8. Plugin extraction: native events become React events
Once React has the target Fiber and native event, it does not immediately call your onClick.
Instead, it first runs the plugin extraction pipeline inside DOMPluginEventSystem.js.
The current source calls plugin extractors such as:
SimpleEventPlugin.extractEvents(...)ChangeEventPlugin.extractEvents(...)EnterLeaveEventPlugin.extractEvents(...)SelectEventPlugin.extractEvents(...)BeforeInputEventPlugin.extractEvents(...)
This is how React turns one native event source into the right higher-level React event behavior.
So the better mental model is:
Native events enter once, then React decides which synthetic events and listeners should come out of that input.
9. Listener collection: React walks the Fiber path, not the DOM manually
After extracting events, React needs to know which React handlers should run.
The key source function here is listener accumulation, such as accumulateSinglePhaseListeners(...).
React starts from the target Fiber and walks upward through the Fiber return chain toward the root. While doing that, it checks whether each relevant host Fiber has matching props like:
onClickonClickCapture
For each match, React creates a dispatch record containing things like:
- the Fiber instance
- the listener function
- the DOM node that should become
currentTarget
Conceptually, the collected queue might look like this:
[
{ listener: parentCapture, currentTarget: div },
{ listener: childCapture, currentTarget: button },
{ listener: childBubble, currentTarget: button },
{ listener: parentBubble, currentTarget: div },
]
The exact internal storage differs by event path, but the big idea stays the same:
React builds a dispatch queue from the Fiber path instead of relying on your handlers being directly attached to each node.
10. Propagation simulation: capture first, bubble second
Once the dispatch queue exists, React executes it in a controlled order.
processDispatchQueue(...) and processDispatchQueueItemsInOrder(...) handle this part.
The important detail is that React can simulate the familiar two-phase model:
- capture listeners run from parent toward child
- bubble listeners run from child back toward parent
That is why onClickCapture feels like DOM capture and onClick feels like DOM bubble even though React is dispatching through its own queue.
This is also where currentTarget gets reassigned for each listener call, then cleared after that listener finishes.
11. What stopPropagation() actually does inside React
Inside SyntheticEvent.js, stopPropagation() does two things:
- call the native event's
stopPropagation()when available - flip React's internal propagation flag
Then inside processDispatchQueueItemsInOrder(...), React checks event.isPropagationStopped() while iterating listeners. If the flag is set, React stops processing later listeners for that propagation path.
So from a practical point of view:
- later React listeners higher in the path stop running
- native bubbling above the current point is also stopped when the browser allows it
This is why React's synthetic propagation is not purely fake. It is layered on top of the native event, not disconnected from it.
12. The tricky mixed case: React handlers vs manual native listeners
This is where bugs often show up.
If you mix:
- React handlers like
onClick - manual listeners like
document.addEventListener('click', ...)
then ordering depends on normal DOM propagation rules, phase, attachment point, and registration timing.
So the safest rule is not "React always runs before native" or "native always runs before React." The safer rule is:
- React handlers run when the native event reaches React's delegated listener
- React 17+ root-level delegation means
stopPropagation()inside React can prevent the event from reaching ancestors outside the root, such asdocumentbubble listeners - but it cannot retroactively stop native listeners that already fired earlier in the propagation path
That framing is much more reliable than memorizing one absolute cross-system order.
13. Why onChange feels different from the browser's old change
React's onChange is one of the best examples of why the plugin system exists.
Native browser behavior around form events has historically been inconsistent across:
- text inputs
- checkboxes
- radio buttons
- file inputs
- old browser edge cases
The current ChangeEventPlugin.js registers several native events to support React's normalized onChange, including:
changeclickfocusinfocusoutinputkeydownkeyupselectionchange
That plugin then decides when to synthesize React's onChange so controlled inputs behave consistently.
This is why React onChange often feels closer to "value changed right now" behavior rather than the older DOM intuition of "change after blur."
14. What this architecture buys React
Once you see the whole pipeline, the design starts to make sense.
React gets:
- a consistent event object API
- delegation that scales better than binding everywhere
- Fiber-aware listener lookup
- custom normalization for hard event types
- cleaner root isolation in React 17+
And developers get the simpler component-facing API:
<button onClick={handleClick} />
<input onChange={handleChange} />
The complexity is still there. React just absorbs it on your behalf.
15. The summary worth remembering
If you only keep one compact model in your head, keep this:
- React installs most native listeners once per root.
- A native event arrives at that delegated listener.
- React finds the closest Fiber from the target DOM node.
- Plugins extract the right synthetic events.
- React collects listeners by walking up the Fiber tree.
- React runs capture and bubble through its dispatch queue.
stopPropagation()affects both native bubbling and React's remaining queued listeners.
That is the real shape of the React Synthetic Event System.
It is not just a wrapper around addEventListener. It is a root-delegated, Fiber-aware, plugin-driven event engine built on top of the browser's native events.
Reviewed by
DevDepth Editor
Editor and frontend engineering writer
DevDepth publishes practical guides on React, Next.js, TypeScript, frontend architecture, browser APIs, and performance optimization.
Each article should be reviewed for technical accuracy, code clarity, metadata quality, and internal-link fit before it goes live.
Last editorial review: 2026-03-17