Fortes


Mixing React and DOM events

Figuring out why your foot got blown off after you inadvertently shot it

Seven Sisters Waterfall

Seven Sisters Waterfall Geiranger, Norway

When possible, avoid mixing React event handlers with the native DOM event handlers. Using React’s event handling is unequivocally the way to go (less code, better for performance, avoids memory leaks, etc). Unfortunately, since you’re reading this, you’re probably in some situation where you can’t just use a React listener. This typically happens when using a non-React component within the React tree. Another scenario is when a component needs to listen to events that fire outside of the scope of the DOM owned by that component, for example listening mousemove event when the mouse leaves the element’s bounds (Pointer Capture solves this scenario, but it’s not universally available yet).

Code nerds should read on, but all others can get by with the following TL;DR:

  • Avoid using addEventListener and rely on React event handlers
  • Listen on document (or window) if you want to receive events after all React handlers. Listen anywhere else in order to receive before React handlers
  • React event handlers will always execute after native capture handlers

Let’s work with the following (simplified) structure:

<html>
  <body>
    <!-- Container element for the React tree, target of ReactDOM.render() -->
    <div id="container">
      <!-- Root React component -->
      <App onClick={onAppClick}>
        <Button onClick={onButtonClick} />
      </App>
    </div>
  </body>
</html>

As you likely know, if you call event.stopPropagation() in your onButtonClick handler, then the onAppClick handler will never be called. Now let’s add a DOM event listener on <body> with the following code:

document.body.addEventListener('click', e => e.stopPropagation());

Now which event handlers will be called after clicking the <Button>? You might (quite reasonably!) assume that, since <body> is an ancestor of <Button>, the onButtonClick and onAppClick handlers get called first. Unfortunately, in this case neither React handler is called! Only the DOM handler on <body> gets called, so what gives?

React has its own event system. The Event argument received by DOM event handler is not the same as the Event argument the React handler receives. This isn’t a problem in most cases, but if your code depends on specific handler order, or any of the handlers calls event.stopPropagation() then we run into situations like the contrived scenario above.

React’s event system works by attaching native event listeners on the document object. Once an event bubbles up to document, React dispatches it’s own SyntheticEvent that bubbles through the React component tree. If you’re using Portals, React is smart enough to route the event through the Portal for you (unlike the DOM event, which can only follow the DOM tree).

Note: React 17 changed how event listeners are set up. Listeners are now attached at the root of the React tree, instead of the document object. This post was written years before React 17 release, but the general concept here is still applicable.

This means that any native event handler that calls stopPropagation() before the event reaches document will cause React to never see that event at all. In (most?) scenarios this is probably the desired result, since the native event handlers are probably there in order to completely override default behavior.

What if you only want to let React handlers get called before your native handler? Since event handlers are called in order of registration, the answer is to listen on the document object (or window, depending on the event). Note that this will not work if you add the handler before the React tree mounts (i.e. before calling ReactDOM.render())! In that case, it’s best to listen on window (or wait until React has mounted before adding the handler).

Things get a bit trickier during the capture phase. Since React is listening to the bubble phase on document, any handler that calls stopPropagation() during the capture phase will prevent React from ever seeing the event (this issue is what inspired me to write this post). I hacked up a simple test to visualize scenarios (feel free to play around at home).

Stopping propagation during capture phase prevents React from firing events

This behavior is fairly understandable once you dig into the details, but it’s completely counterintuitive to anyone unfamiliar with the difference between the React and native event systems. Unless you’re paid per bug fixed, I highly recommend avoiding mixing the two systems. I learned this lesson too late. My penance was to write this post, yours to read it, and now we’re both done.