onClick={wat}

Drew Wyatt
Peloton-Engineering
5 min readMay 22, 2019

Let’s talk about events in React

What is React actually doing with that onClick prop? Until very recently, I assumed it was doing this:

(or something)

If you start digging around the section on events in the React documentation, you will find that React uses something called SyntheticEvent.

Your event handlers will be passed instances of SyntheticEvent, a cross-browser wrapper around the browser’s native event. It has the same interface as the browser’s native event, including stopPropagation() and preventDefault(), except the events work identically across all browsers.

Okay, that makes sense. What else?

The SyntheticEvent is pooled. This means that the SyntheticEvent object will be reused and all properties will be nullified after the event callback has been invoked. This is for performance reasons. As such, you cannot access the event in an asynchronous way.

Alright. So React does this for compatibility and performance. What’s the big deal though?

Well, it’s not just SyntheticEvent that’s important here.

The SyntheticEvent is pooled

By what?

Let’s take a look at react-dom. Specifically:

This is pretty complex. Really the only thing we care about here is the first sentence though:

Top-level delegation is used to trap most native browser events.

You can see an example of this if you look at the Event Listeners panel in Chrome’s dev tools and inspect an item you have assigned an onClick handler to.

Screenshot of Chrome dev tools inspecting a button on members.onepeloton.com

Notice in the example above (and probably also in your own dev tools) that while there is an event listener attached to the button, the handler prop is noop(). Additionally, you can remove this listener (using the dev tools) and whatever you attached to onClick still executes when you click the button.

That’s because the event listener that is executing your handler is attached to the document (you can validate this by removing that listener and noticing that your handler does not execute anymore).

What you are seeing here (attached to document) is the global event delegate that picks up virtually every event that can be attached via onClick/onDrag/onFocus/onYouGetTheIdea. You can see a full list here.

Why does this matter?

Fair question. Candidly, most of the time none of this matters. How React chooses to fire a given handler when a button is clicked is something that the developers using React almost never need to think about…

…unless you are using a third-party library that is not attaching its event listeners with React.

E.g.

I am member of the Digital team at Peloton. One of the projects I work on is the video player for members.onepeloton.com. It looks like this:

In class video player experience on members.onepeloton.com

We render custom controls (built in React) that we place in an overlay on top of JWPlayer, which handles the streaming (and does not use React).

Out of the box, JWPlayer attaches a number of event listeners to elements to handle common use cases (e.g. using spacebar to pause or play the video). Most of these are helpful.

However, in some cases, these event listeners are not helpful. One instance concerns keyboard accessibility for our custom volume control.

If a user is using their mouse, hovering over the speaker icon (🔊) will show a volume slider that can be adjusted using the mouse. When using a keyboard, however, this requires some extra steps:

  1. Tab to speaker icon
  2. Press enter or spacebar to open the slider
  3. Tab to the slider
  4. Use the arrow keys to adjust the volume

After implementing this behavior, we ran into a frustrating issue: hitting enter or spacebar did open the volume slider, but it also toggled the play/pause state of the video.

No problem, right? All we needed to do was add event.stopPropagation() to to our event handler.

That didn’t work.

hmmm… Okay. event.preventDefault() it is.

Nope.

event.stopImmediatePropagation()?

Uncaught TypeError: event.stopImmediatePropagation is not a function

Okay, well I haven’t done this in a while, return false;?

still no.

What’s going on here?

The problem (this is where the first half of this article becomes relevant) is that JWPlayer is attaching its native event listener directly to some element on the page that our 🔊 button is a child of. Our button’s handler is attached to document (the global, top-level delegate we learned about above). This event is going to bubble to JWPlayer’s handler long before it hits ours. So, by the time we call stopPropagation() (or anything else), it’s too late.

What can we do?

We do the thing that I used to think onClick was doing.

DISCLAIMER

There are lots of reasons that React handles events the way it does:

  • performance
  • cross-browser compatibility
  • bubbling order (especially if you are using something like portals)

USE THIS SPARINGLY (and only when absolutely necessary)

With that out of the way, let’s make a hook!

Let’s break this down

This hook takes an event handler and returns a ref. The handler gets attached directly via addEventListener (instead of with onClick).

createRef

This hook creates a ref (surprise) and returns it, so that we can attach it to the element we need to assign our handler to.

useEffect

This effect calls addEventListener on the element the ref gets attached to. Note that we specify the ref as a dependency, and only call addEventListener once it has been attached.

stopPropagationAndUseHandler

This helper function saves us from having to add the boilerplate of calling stopImmediatePropagation to the handler we pass to the hook.

How do we use it?

In this example, we are wrapping button, and overriding the behavior of onClick, so that we attach the returned ref from useNativeEvent, instead of applying directly to onClick.

This is everything I learned about event handling in React while trying to fix an extremely frustrating bug. Problems like the one my team encountered can be maddening. I hope this either saves you from a painful day of debugging, or fills in some gaps about React internals that you may have taken for granted.

Have you had a similar experience? Is there something I missed/misinterpreted? Did this make something click for you? Please share in the comments or find me on twitter.

👋

--

--