The React and React Native Event System Explained: A Harmonious Coexistence
You’re using it. You’re liking it. But did you know what React’s event handler is doing under the hood?
There are an awful lot of posts explaining how to use React’s event handling system, but not many that explain how it works. I have been working on React Native lately, and my struggles with event handling acted as a reminder of how important it was to understand precisely what’s going on. I thus decided to gather as much info as possible regarding event handling in React: the following is a report of what I found looking around the source code.
Let’s go!
Event handling in React: An overview
Conceptually speaking, event handling in React is nothing revolutionary. Its only goal is intercepting various events (clicks, touches…) and triggering the associated callbacks that you, the programmer, coded. It’s the implementation that makes React’s event handling system stand out.
One thing React emphasizes is harmonization: cross-browser for React web, cross-platform for React Native. But the event system actually takes this concept one step further by having an (almost) identical event processing system for both React web and React Native. That’s right: both DOM and native events are treated using — minus a little bit of pre-processing —the exact same code. How does React pull off this magic trick? This could be the subject of an article — if not several — in itself, so let’s try to be brief.
Welcome to the magical world of Fiber
What happens when an app updates (let’s say, after clicking a button)? New information propagates, and the app has to be rendered again with it. Now, the core idea behind React (web or native) is to cut this very process into two separate phases: “reconciliation” — where React calculates differences and decides what updates are needed — and “rendering” — where the updates are actually applied. See where that leads us? You’re right. The “reconciliation” phase does not care about how or where the rendering is done, only about what should be rendered. Consequently, the same process can be used for both React Native and React web. The only remaining task to be done is plugging in the appropriate rendering engine.
Event processing is part of the “reconciliation” phase, and thus take place in the same abstract world, where browser events and DOM components are no different from native events and components. What does that world look like? It might be a little complex to picture, since we are so used to thinking in terms of visible, tangible objects, but in this parallel universe, each and every component becomes a Fiber
. Indeed, as the React reconciliation algorithm does not care about how components are rendered but only about what changed between two render iterations, components themselves do not matter. Only the work that has to be done to go from the previous state of the component to the new state matters (that work can also be no operation, if no changes happened). And that’s what a Fiber
is: not a physical entity but a unit of work, a small step in the grand scheme of the reconciliation process.
For those whose curiosity has been spiked by the previous introduction to Fiber
s, I advise you to learn more on Fiber
and React Fiber! This fun video presentation by Lin Clark is a good start. For everyone else, do not worry: understanding Fiber is absolutely not required to grasp the rest of this article (the switch to Fiber is rather recent, and the event management system did not undergo any major changes in the process anyway). The thing to remember is this: React works in an “abstract world” where updates are made independently of the physical representation of the component: the multiple “real worlds” (the browser, your phone…) where components are rendered are but projections of that unique, device-independent universe. Event handling is no different, and pretty much everything happens in this “abstract world” —whether the event initially came from the DOM or from native, it does not matter.
In the case of event handling, the “listening, normalizing, & re-emitting” phase exists precisely for the purpose of transforming real events and components into their abstract counterparts. It captures native events coming from components, and turns them into what React calls a topLevelType
associated with a Fiber
. As a result, native events and components themselves are effectively invisible to the downstream event processing system, and no handlers are installed in the “real” environment: everything takes place in the virtual DOM.
Receiving (listening to) events
Alright, looking at the above drawing, it seems that in every case the event handling starts with a listening phase. This is little surprising. After all, many of us are used to having to define our own custom listeners in our applications — because we want it to react only to click
and not mousescroll
for example. But why would React itself need to listen to all events? It’s becase events appear in their “natural” environment: the DOM for web applications, and native on your mobile device. React, be it in its web or native flavor, is a tool built on top of these fundamental environments. As a result, events do not naturally go through React, and it has to actively listen to them.
Receiving events: React web
For React web, the process is fairly simple and uses top-level delegation. This means that React listens to every event at the document
level, which has an interesting implication: by the time any React related code is executed, events have already gone through a first capture/bubbling cycle across the DOM tree.
After receiving that event from the browser, React performs an additional cross-browser harmonization step. As a workaround for browsers having different names for what is effectively the same event, React defines topLevelTypes
that are wrappers around browser-specific events. For instance, transitionEnd
, webkitTransitionEnd
, MozTransitionEnd
and oTransitionEnd
all become topAnimationEnd
— effectively alleviating part of the pain of designing cross-browser applications via consolidation.
Receiving events: React Native
For React Native, events are received over the bridge that links native code with React. In short, whenever a View
is created, React also passes its ID number over to native, so as to be able to receive all events related to that element. Again, slight modifications are performed before passing the (touch) event downstream, including adding the touches
and changedTouches
arrays to the event in order to make it W3 compliant.
From now on, in order to differentiate them for the SyntheticEvents
that will be introduced later, we will refer to what we have called “events” (i.e. event objects, coming from either native or the browser, that underwent slight modifications) as “native events.”
The innards of React’s event management system
We now have our native events, harmonized across platforms and browsers. Great! We are now ready to start the real work: passing these events to the appropriate callback(s). Such is the duty of React’s event system. Let’s take a closer look.
Whew, there’s quite a lot of stuff everywhere. Still, EventPluginHub
and its event plugins stand out of the lot. EventPluginHub
is in fact the keystone of the entire system, as it:
- Provides a unified interface for event plugins to be injected into.
- Runs through the injected plugins every time a new native event is received, collecting the
SyntheticEvents
returned before dispatching them all.
On the other hand, event plugins all have a similar structure and take native events as inputs, outputting one or several SyntheticEvents
, complete with an array of dispatches (functions) to be executed at a later stage. SyntheticEvent
is a React specific wrapper around native events, that essentially have the same interface as the browser events you’re already used to, including stopPropagation()
and preventDefault()
(for more information, the official documentation on events has a dedicated page here).
Event plugins
Although there is a wide variety of different plugins for events, including SimpleEventPlugin
(that handles onClick
, onTouch
, etc.) and the famous ResponderEventPlugin
, they all follow the same pattern:
- Create one or more
SyntheticEvent
s in response to native events. - Collect all dispatches (i.e. the functions provided by you, the coder) associated to one
SyntheticEvent
(for example, thedoStuff
inonTouchStart={doStuff}
). - Returning every
SyntheticEvent
along with its dispatches.
The thing worth noting here is that no dispatches are actually executed in the plugin, as it only collects the functions themselves. (Most of the time, that is — some plugins do execute specific dispatches during the collection stage, but this is the exception rather than the norm). The SyntheticEvent
s can simply mirror native events (like click
or drag
), or be more complex (like touchTap
), but are in all cases returned with their dispatch array attached, so as to be “ready for processing”.
To collect dispatches, React runs a double traversal of the component (be it native or DOM) tree, with a capture and bubbling phase that go from the root to the nested target (capture phase) back to the root (bubbling phase).
Note that for all differed dispatches (the ones executed outside the plugin itself, which is to say most of the dispatches), the double traversal happens in full. Interruptions such as stopPropagation()
will take effect at dispatch time, effectively preventing the execution of subsequent functions for this SyntheticEvent
only (see conclusion).
The EventPluginHub
All event plugins are injected into the EventPluginHub
when the app launches, and plugins are sorted following a configuration file. Then, at runtime, EventPluginHub
will perform the following each time it receives a native event:
- For each plugin (in order), gather all
SyntheticEvents
and their dispatch configuration and store them in the queue. - Execute all dispatches for all events in the queue, effectively clearing it.
And that’s it! Your callbacks are executed with the right event. :)
Consequences & conclusion
An interesting consequence of this system is that a single native event can (and will, most of the time) generate multiple SyntheticEvent
s, each with a scope limited to the plugin that created it. This means that:
- Only the
nativeEvent
part of theSyntheticEvent
will be passed from plugin to plugin, so while modifications tonativeEvent
can have repercussions over the executions of subsequent plugins, modifications to theSyntheticEvent
cannot. - Due to the limited scope of the
SyntheticEvent
, calling methods such asstopPropagation()
will only work for one event plugin.
As an example of that second point, let’s imagine that we have two plugins, A
and B
, defining, respectively, the synthetic events eventA
and eventB
. We will assume that these events have the following names: onEventA
and onEventB
for the bubbling phase, and onEventACapture
and onEventBCapture
for the capturing phase. Finally, both are triggered by the same top level type (say, topClick
) and are ordered [A, B]
. Now consider the following code in React Native (simply replace View
with div
for React web):
Any click event would first trigger a capturing phase for eventA
, calling stopPropagation()
in the nested component and effectively preventing the following bubbling phase. As expected, 'onEventA'
will not appear. However, since eventB
has been defined in a different plugin and therefore relies on a different SyntheticEvent
, 'onEventB'
will end up being printed to the console. Although this is arguably a pretty edge-case scenario, I can see times where this could cause unexpected behaviors.
There would of course be more to say about React’s event handling system, such as the fact that SyntheticEvents
are actually pooled, but I have avoided these here in order to avoid overkill.
I sure learned a lot from traveling all around the code base (there is sadly not much in-depth documentation on this topic), and also from watching that great video by Kent C. Dodds, Dan Abramov & Ben Alpert. I hope that you might have learned a thing or two as well!
As for me, I’m going to continue having fun looking at how things work under the hood…