Skip to content
DevDepth
← Back to all articles

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.

Published: Updated: 8 min readreact-internals

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 SyntheticEvent objects
  • 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:

  • nativeEvent points to the original browser event
  • target is derived from the native target
  • currentTarget starts as null and is assigned while each listener runs
  • preventDefault() forwards to the native event and updates React's own flag
  • stopPropagation() 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:

currentTarget is only meaningful during listener execution because React assigns it per dispatch and then resets it to null.

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:

  • scroll
  • load
  • invalid
  • toggle
  • several media-related events

There is also one special case worth calling out:

  • selectionchange is attached to the owning document, 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:

  • onClick
  • onClickCapture

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:

  1. call the native event's stopPropagation() when available
  2. 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 as document bubble 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:

  • change
  • click
  • focusin
  • focusout
  • input
  • keydown
  • keyup
  • selectionchange

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:

  1. React installs most native listeners once per root.
  2. A native event arrives at that delegated listener.
  3. React finds the closest Fiber from the target DOM node.
  4. Plugins extract the right synthetic events.
  5. React collects listeners by walking up the Fiber tree.
  6. React runs capture and bubble through its dispatch queue.
  7. 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

Contact the editor