Skip to content

Event delegation - The React Way

React events look like DOM events, swim like DOM events, and quack like DOM events - but they're not DOM events. And that difference explains why your stopPropagation() calls behave unexpectedly in React.

Try to guess which console logs will be printed when you click the button:

The radio buttons below are used to configure the behavior of the parent event handler.

Loading...
event.stopPropagation
  • Parent clicked
  • Root El clicked
event.stopImmediatePropagation
  • Parent clicked
  • Root El clicked
event.nativeEvent.stopPropagation
  • Parent clicked
  • Root El clicked
event.nativeEvent.stopImmediatePropagation
  • Parent clicked

Did you get all of them right? Do you know why? Let's find out.

We will first define related terms and show what they do in plain JavaScript; skip ahead if you already know this part.

Table of Contents

Event

An event is a signal sent by the browser (or your environment) to notify you that an action or behavior has occurred. You can then attach listeners to these signals so your code can react to the events.

These events fire in the browser, so their behavior depends on the browser environment rather than on native JavaScript. Fortunately, their behavior has been standardized across browsers for some time.

const button = document.getElementById("button");

/* On which element do I listen to the event */
button.addEventListener(
  /* Type of event to listen to */
  "click",
  /* What to do when this event happens */
  () => {},
);

Event propagation

In the earlier example, we add a click listener to a button, which makes sense because the button is the element being clicked. But what if we want to detect clicks outside the button? For instance, a popover appears when you click the button, and you want it to disappear when you click anywhere else. You could attach an event listener to every element on the page, but that would quickly become unmanageable. Fortunately, events don't stop at the clicked element - they travel up and down the DOM tree.

When you interact with the page - say, by clicking an element - the click event travels from the top of the window down to the target element, then back up again. The downward journey is the Capture phase; the upward return is the Bubble phase.

DOM Event Visualizer

Click a node to trigger an event simulation

Speed:
Event Phases
Capture (Down)
Target
Bubbling (Up)
WindowDocumentHTMLBODYSECTIONP.introMAINDIV.cardBUTTON

Console Output

Waiting for event...
Click a node in the tree.

Bubbling phase

The bubbling phase is the more commonly used of the two. It flows bottom-up - starting from the target element and traveling up to the root.

Not every event will bubble 🧋

Not all events bubble. For example, the focus event does not bubble. This is because the focus event fires only on the focused element itself, not on its descendants.

Scroll events also do not bubble. This is because the scroll event fires only on the scrollable element itself, not on its descendants.

Event delegation

Event delegation is a technique for listening to events on a parent element instead of the target element. This is useful when you have many elements that need to respond to the same event - a direct application of the bubbling phase.


event stopPropagation

stopPropagation() prevents an event from continuing to bubble up the DOM tree. When called on an event, parent elements won't receive the event.

const child = document.getElementById("child");
const parent = document.getElementById("parent");

child.addEventListener("click", (event) => {
  event.stopPropagation();
  console.log("Child clicked"); // This runs
});

parent.addEventListener("click", () => {
  console.log("Parent clicked"); // This never runs
});

This is useful when you want an element to handle an event without triggering parent handlers - like preventing a card click when clicking an internal button.

event stopImmediatePropagation

stopImmediatePropagation() goes further: it stops bubbling and prevents other listeners on the same element from executing.

const button = document.getElementById("button");

button.addEventListener("click", (event) => {
  event.stopImmediatePropagation();
  console.log("First listener"); // This runs
});

button.addEventListener("click", () => {
  console.log("Second listener"); // This never runs
});

Use this when you need to ensure your handler is the only one that responds to an event on that element.

Visualization Time ⏲️

Child Handler 2 Configuration

Interactive Canvas (Click Inner Box)

Parent Node
Child Node
Click Me

Attached Listeners

Child Listener 1
Plays C4 (261.6Hz)
child.addEventListener('click', handler1)
Child Listener 2
Plays E4 (329.6Hz)
child.addEventListener('click', (e) => {
  playTone(E4);
})
Child Listener 3
Plays G4 (392.0Hz)
child.addEventListener('click', handler3)
Parent Listener
Plays C5 (523.2Hz)
parent.addEventListener('click', handler)
Execution Log
Waiting for click event...

Synthetic Events - React's Event System

React's synthetic event system is a wrapper around the browser's event system. React creates an event object for the native event and then dispatches it to the target element.

When you listen to an event through a React element, the event object that you see is a synthetic event object. This is a wrapper around the native event object that is created by React.

The inspector below will populate once you click.

React's custom event system started as an effort to normalize browser behavior. By managing events internally, React controls how bubbling works across all events. In fact, as mentioned earlier, even if you listen at the parent level, you'll find that the bubbled event is still a React synthetic event. (Caveat: this applies when using React event handlers.)

Modern browsers now handle events consistently, so they behave almost identically across platforms. React's synthetic event system has turned this consistency into a performance advantage: it can batch state updates inside event handlers, catch errors through error boundaries, and more.

How does React achieve this?

If we think about this naively, React should attach an event listener to every DOM element out there. When there are apps around with 10k DOM elements or so, this becomes a bottleneck.

But there's an interesting solution we already discussed: event bubbling. Many events naturally bubble. When React detects that a user has attached an event handler (like onClick), it adds an onClick to the root React element. Once the event bubbles up to the root, React identifies the event and its origin, then passes the same information to the original event handler (our onClick listener). This way, React has far fewer event listeners on the DOM but still handles all of them.

How do we access the native event object?

Easy! event.nativeEvent

What is the difference between event.stopPropagation() and event.nativeEvent.stopPropagation()?

event.stopPropagation() stops the React event from bubbling up the DOM tree.

event.nativeEvent.stopPropagation() stops the native event from bubbling up the DOM tree.

However, we discussed how React event listeners are attached to the root element instead of the element itself. So for React to actually know the event is happening, the native event needs to bubble to the root element.

This causes the interesting behaviour that we saw at the beginning.

Explaining the logs

Case 1: event.stopPropagation() is called in the React event listener.

This stops the React event from bubbling up the DOM tree. However, the native event still bubbles to the root element, so the root element's event listener will still run.

Case 2: event.stopImmediatePropagation() is called in the React event listener.

This stops the React event from bubbling up the DOM tree and prevents other event listeners on the same element from executing. However, the native event still bubbles to the root element, so the root element's event listener will still run.

Case 3: event.nativeEvent.stopPropagation() is called in the React event listener.

This stops the native event from bubbling up the DOM tree. However, note that this is in React event handler. For React to know the event is happening, the native event needs to bubble to the root element. The root element's event listener will still run.

Case 4: event.nativeEvent.stopImmediatePropagation() is called in the React event listener.

This stops the native event from bubbling up the DOM tree and prevents other event listeners on the same element from executing. Remember where the native event is attached on React? It's on the root element. If you stopped immediate propagation on root element, the other event handlers on the same root element will not run. Hence - no console logs from root element this time.

Case 5: What if instead of React event handler, you attached a native event handler to the button?

The End

React events may look like DOM events and swim like DOM events, but they operate through a sophisticated delegation system that prioritizes performance and consistency. By understanding how React manages events under the hood, you can avoid the confusion when stopPropagation() doesn't behave as expected and make informed decisions about when to use React's synthetic events versus native DOM events.


Stay ahead of the curve in Web Development with Javascript Every Month Newsletter.

I will deliver a curated selection of articles, tutorials, and resources straight to your inbox once a month.

Read the Archives

Subscribe to JEM Newsletter

No spam, unsubscribe anytime.