Recently, I came across an interesting problem involving Event Propagation in React. I want to share my discovery around React’s synthetic event system, in the hope that you won’t spend several hours, like I did, questioning your existing understanding of JavaScript events.
In order to explain the issue I had, let’s briefly re-cap how events work in JavaScript. There are three phrases in Event Propagation: capture, target and bubble (in that order).
If you specify an event listener with the useCapture
option, this tells the engine to invoke the that listener first, before the target’s listener. If there are multiple capture listeners for the same event, they will fire in the order they are registered.
After the capture listeners, if any, the listeners attached to the target element are invoked.
The final phase is where all the target parent’s event listeners are invoked, all the way up to the window
object. You can stop the event from propagating to the next parent by calling e.stopPropagation()
, where e
is the event.
We can avoid creating listeners for specific nodes by attaching a single event listener to a parent. We can then look at the target element (e.target
) to determine if and what action to take by leveraging bubbling. This is called Event Delegation.
We have a modal overlay component that shows up when the app’s search bar is engaged. It simply displays some content over the top of the current page.
In order to do this, we’re going to add two event listeners, a mousedown
and a keydown
. Since we are going to be detecting if the user is clicking outside the component, we need to use Event Delegation by attaching the listener to the document when the component mounts.
componentDidMount() {
document.addEventListener('mousedown', this.handleOutsideClick);
}
Remember to remove it when the component unmounts otherwise unicorns will die ๐ฆ ๐ป
componentWillUnmount() {
document.removeEventListener('mousedown', this.handleOutsideClick);
}
In our handler, we need to check whether the element we clicked on is inside the modal container. We do this by using a ref
to get hold of the container node.
handleOutsideClick(e) {
if !(this.modalRef.current.contains(e.target)) {
this.closeModal();
}
}
We can do the same thing for the keydown
event, but detect the escape key instead.
Inside the modal, we need to add some content. In our case, there will be some search results and some filter options. The filters are dropdowns, and to be keyboard accessible, we should have an event listener that will close the filter when the user presses the escape key.
handleKeyDown(e) {
if (e.keyCode === keys.escape) {
this.closeDropdown();
}
}
<div className="filter-dropdown" onKeyDown={this.handleKeyDown}>
<ul>
...
</ul>
</div>
If I was to open the filter dropdown and press the escape key, I might expect the dropdown to close. However, what actually happens is the whole modal closes instead.
Remembering how events work, we’ll realise that the target’s event listener will fire first, but the event will bubble up the parent. The document
also has a keydown
event listener for the modal, so that will fire too, closing the whole modal. This is not what we want.
Let’s make a small change:
handleKeyDown(e) {
if (e.keyCode === keys.escape) {
this.closeDropdown();
e.stopPropagation();
}
}
This should stop the event from bubbling to the parent, thereby preventing the modal from closing. However, when we try this, it still closes the modal. What is going wrong?
I put some console logs in the event handlers to see what was going on. Pressing escape on the filter had the following result:
FilterDropdown (React): escape triggered with stopPropagation
ModalOverlay (Native): escape triggered
Native event handlers still propagate if you use e.stopPropagation()
in React events. So let’s try converting the React event to a native one on the element.
componentDidMount() {
this.element.current.addEventListener('keydown', this.handleKeyDown);
}
As always, think of the unicorns ๐ฆ
componentWillUnmount() {
this.element.current.removeEventListener('keydown', this.handleKeyDown);
}
Trying this again, the dropdown closes and the modal remains open.
FilterDropdown (Native): escape triggered with stopPropagation
It turns out, React uses Event Delegation behind the scenes, and uses its own event flow to determine which handlers to invoke.
Event delegation: React doesn’t actually attach event handlers to the nodes themselves. When React starts up, it starts listening for all events at the top level using a single event listener. When a component is mounted or unmounted, the event handlers are simply added or removed from an internal mapping. When an event occurs, React knows how to dispatch it using this mapping. When there are no event handlers left in the mapping, React’s event handlers are simple no-ops.
Source: https://github.com/facebook/react/issues/7094
Since React uses a synthetic event system, the native event will go through the normal capture, target and bubbling phases, and then React’s event flow will follow, provided that the native event doesn’t stop propagation, as in my case.
In the mostly rare cases where you find yourself mixing React events and native events, don’t! If you need Event Delegation, use only native event listeners for that particular event type. In all other cases, you should use React events, as they are come with performance benefits.
There is some discussion about changing the way React’s events work, with suggestions such as React Root Listeners and Element Listeners. You can read the thread DOM Event Mount Target Considerations. This is all part of the strategy for React Fire.