Getting to know React DOM's event handling system inside out
It all started when I’ve tried to redirect submitted React event handlers into another DOM element.
I won’t get into details regarding the use case, but what I did was fairly logical: I’ve redefined
the addEventListener()
method on the DOM element’s instance, hoping to capture the submitted
arguments and do as I wish with them. Unfortunately, it didn’t work…
How come?! How could it be that React handles events without calling the addEventListener()
method? After all, it has proven itself to work, across many applications.
True, but it’s not what you think. First I would like you to take a snapshot of ReactDOM’s implementation. It actually has a comment which explains the entire event handling system:
Summary of `ReactBrowserEventEmitter` event handling:
- Top-level delegation is used to trap most native browser events. This may only occur in the main thread and is the responsibility of ReactDOMEventListener, which is injected and can therefore support pluggable event sources. This is the only work that occurs in the main thread.
- We normalize and de-duplicate events to account for browser quirks. This may be done in the worker thread.
- Forward these native events (with the associated top-level type used to trap it) to `EventPluginHub`, which in turn will ask plugins if they want to extract any synthetic events.
- The `EventPluginHub` will then process each event by annotating them with "dispatches", a sequence of listeners and IDs that care about that event.
- The `EventPluginHub` then dispatches the events.
At the beginning this is what I saw:
But after debugging a little, going through the stack trace and some of React’s documentation, things are much clearer now. Let’s break it down then, and try to make things simpler.
Top-level delegation is used to trap most native browser events. This may only occur in the main thread and is the responsibility of
ReactDOMEventListener, which is injected and can therefore support
pluggable event sources. This is the only work that occurs in the main thread.
React uses a single event listener per single event type to invoke all submitted handlers within the virtual DOM. For example, given the following React component:
const ExampleComponent = () => (
<div onClick={onClick}>
<div onClick={onClick} />
</div>
)
We will have a single event listener registered on the native DOM for the click
event. By running
the getEventListeners()
method which is available on Chrome dev-tools, we would get the following
result:
{click: Array(1)}
Each event-type listener will be ensured per single render cycle, so if we were to define additional
event handlers of keydown
type, we would get the following output:
{click: Array(1), keydown: Array(1)}
Source: packages/react-dom/src/client/ReactDOMComponent.js:225
We normalize and de-duplicate events to account for browser quirks. This may be done in the worker thread.
For each and every browser, regardless of its implementation, we will have consistent event
arguments, as React normalizes them. Whether we use the latest Chrome browser or IE8, the click
event arguments will look like so:
- boolean altKey
- number button
- number buttons
- number clientX
- number clientY
- boolean ctrlKey
- boolean getModifierState(key)
- boolean metaKey
- number pageX
- number pageY
- DOMEventTarget relatedTarget
- number screenX
- number screen
- boolean shiftKey
Docs: events:supported-events Source: packages/react-dom/src/events/SimpleEventPlugin.js:259
Since React is registering a single event listener per multiple handlers, it would need to re-dispatch the event for each and every handler.
Source: EventPluginHub.js:168
Forward these native events (with the associated top-level type used to trap it) to `EventPluginHub`, which in turn will ask plugins if they want to extract any synthetic events.
The EventPluginHub
is a very central component in React’s event handling system. This is what
unifies all event plug-ins into a single place, and will redirect dispatched events to each and
every one of them. Each plug-in is responsible for extracting and handling different event types,
for example, we have the SimpleEventPlugin
will handle events which are likely to be implemented
across most browsers like mouse events and key presses
(source);
we also have the ChangeEventPlugin
which will handle the very famous onChange
event
(source).
Synthetic events are React’s normalized event arguments which ensures that there’s consistency across all browsers, and are being generated by the plug-ins. Note that synthetic events are being pooled! Which means that the same object instance is used in multiple handlers, only it is being reset with new properties before each and every invocation and then disposed:
function onClick(event) {
console.log(event) // => nullified object.
console.log(event.type) // => "click"
const eventType = event.type // => "click"
setTimeout(function () {
console.log(event.type) // => null
console.log(eventType) // => "click"
}, 0)
// Won't work. this.state.clickEvent will only contain null values.
this.setState({ clickEvent: event })
// You can still export event properties.
this.setState({ eventType: event.type })
}
Docs: events:event-pooling Source: packages/react-dom/src/events/SimpleEventPlugin.js:322
The `EventPluginHub` will then process each event by annotating them with "dispatches", a sequence of listeners and IDs that care about that event.
As mentioned, each and every event can have multiple handlers, even though each of them is actually being listened once by the real DOM. Accordingly, the relevant “dispatches” which consist of event handlers and their corresponding fiber nodes (nodes in the virtual DOM tree) need to be accumulated for future use.
The `EventPluginHub` then dispatches the events.
The plug-in hub goes through the accumulated information and dispatches the events, thus invoking the submitted event handlers.
So that’s how that events handling system works in a nutshell. There are few things I would like you to note:
- Top level event listeners which are registered to the main DOM (
window.document
) can also be registered to other DOMs, depends on where application container’s at. For example, if the container is adopted by aniframe
, then theiframe
’s DOM will be the main event listener; it can also be a document fragment, a shadow DOM, etc. It’s important that you’d be aware of that and know that there’s a slight limitation the events’ propagation. - React re-dispatches the events in two phases: one for capturing and the other for bubbling, just like how the native DOM does.
- The event handling which is done for React Native is different from React DOM’s, and you shouldn’t confuse between the two! React is just a library that produces a virtual representation of the view that we would like to render, and React DOM/Native are the bridge between React and the environment that we’re using. This article is relevant for React DOM only!
At the end of the day you’ll still be able to use React, with or without this information, but I think that a vastly used library such as React deserves more attention, especially if you want to step up your game.
So getting back to what brought me to write this article, if I wanted to redirect the registered by
React, all I had to do was redefining the addEventListener()
for the DOM, and not the
corresponding Node. Of course, overwriting a native method is NOT something that should be done, and
it’s a very bad practice (*cough cough* Zone.js), but I won’t get into my specific use case as
this is a topic for another article.
Update: (November 21st, 2018)
For those who liked this article and how I analyze React’s implementation, I recommend you to read my article about React Hooks and how they work under the hood.
Join our newsletter
Want to hear from us when there's something new?
Sign up and stay up to date!
*By subscribing, you agree with Beehiiv’s Terms of Service and Privacy Policy.