You’re using it. You’re liking it. But did you know what React’s event handler is doing under the hood?
Stuff can sometimes get surprisingly messy if you don’t know how it works…
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.
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.
An overview of React’s event handling flow
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
Fibers, 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,
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
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.
Flow of events inside React’s event system
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
SyntheticEventsreturned 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
preventDefault() (for more information, the official documentation on events has a dedicated page here).
Although there is a wide variety of different plugins for events, including
SimpleEventPlugin (that handles
onTouch, etc.) and the famous
[ResponderEventPlugin](https://facebook.github.io/react-native/docs/gesture-responder-system.html), they all follow the same pattern:
Create one or more
SyntheticEvents in response to native events.
Collect all dispatches (i.e. the functions provided by you, the coder) associated to one
SyntheticEvent(for example, the
SyntheticEventalong 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
SyntheticEvents can simply mirror native events (like
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).
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
SyntheticEventsand 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:
nativeEventpart of the
SyntheticEventwill be passed from plugin to plugin, so while modifications to
nativeEventcan have repercussions over the executions of subsequent plugins, modifications to the
Due to the limited scope of the
SyntheticEvent, calling methods such as
stopPropagation()will only work for one event plugin.
As an example of that second point, let’s imagine that we have two plugins,
B, defining, respectively, the synthetic events
eventB. We will assume that these events have the following names:
onEventB for the bubbling phase, 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
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
**'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…