Custom Event Names and the Bubbling Problem
Tags: webcomponents, javascript
Published: 2022-12-12 22:07:18

The topic of custom element event names comes up every now and then, especially from [Shoelace](https://shoelace.style/) users who get confused when events of the same name are emitted from different components.

Take [`<sl-details>`](https://shoelace.style/components/details), [`<sl-dialog>`](https://shoelace.style/components/dialog), and [`<sl-dropdown>`](https://shoelace.style/components/dropdown), for example. They all emit `sl-show` and `sl-hide` events, allowing you to hook into their lifecycle as they open and close. Occasionally, someone will nest a component inside another, listen for an event such as `sl-hide`, and wonder why the callback executes at the "wrong time."

In this example, you can see that we have an `<sl-dropdown>` inside of an `<sl-dialog>`. Both of these components emit `sl-show` and `sl-hide` events.

```html
<sl-dialog label="Dialog">
  <!-- Note how the dropdown is nested inside the dialog -->
  <sl-dropdown>
    <sl-button slot="trigger" caret>Open and close me</sl-button>
    <sl-menu>
      <sl-menu-item>Option 1</sl-menu-item>
      <sl-menu-item>Option 2</sl-menu-item>
      <sl-menu-item>Option 3</sl-menu-item>
    </sl-menu>
  </sl-dropdown>
</sl-dialog>

<sl-button id="open">Open Dialog</sl-button>

<script>
  const dialog = document.querySelector('sl-dialog');
  const openButton = document.getElementById('open');

  // Show the dialog when the button is clicked
  openButton.addEventListener('click', () => dialog.show());

  // Listen for the sl-show event
  dialog.addEventListener('sl-show', event => {
    console.log('The dialog has opened…or has it?')
  });

  // Listen for the sl-hide event
  dialog.addEventListener('sl-hide', event => {
    console.log('The dialog has closed…or has it?')
  });
</script>
```

[Try it on CodePen](https://codepen.io/claviska/pen/vYrwjyP)

The logic above is faulty because it runs when the dialog _or_ any of the dialog's children emit the `sl-show` or `sl-hide` event. This occurs due to [event bubbling](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events#event_bubbling), which is an incredibly useful feature of the platform. When events bubble up, listeners on ancestor elements will execute. This enables an extremely useful feature called [event delegation](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events#event_delegation).

Alas, this is also a source of confusion.

Fortunately, there are a couple easy ways to work around it.

### Checking the Target

Events are emitted with a `target` property that you can inspect to determine which element emitted it. Thus, we can modify the handlers from the previous example to ensure the target is the same dialog we attached the listener to.

```js
// Listen for the sl-show event
dialog.addEventListener('sl-show', event => {
  if (event.target === dialog) {
    console.log('The dialog has opened')
  }
});

// Listen for the sl-hide event
dialog.addEventListener('sl-hide', event => {
  if (event.target === dialog) {
    console.log('The dialog has closed')
  }
});
```

[Try it on CodePen](https://codepen.io/claviska/pen/VwdOxdO)

### Checking the Current Target

Another way is to compare `Event.currentTarget`, which effectively does the same thing.

```js
// Listen for the sl-show event
dialog.addEventListener('sl-show', event => {
  if (event.target === event.currentTarget) {
    console.log('The dialog has opened')
  }
});

// Listen for the sl-hide event
dialog.addEventListener('sl-hide', event => {
  if (event.target === event.currentTarget) {
    console.log('The dialog has closed')
  }
});
```

[Try it on CodePen](https://codepen.io/claviska/pen/eYKabQM)

### Checking the Event Phase

Here's another way to ensure the event was emitted by the expected element. It's a bit more verbose, but it doesn't require a reference. Instead, we can inspect [`Event.eventPhase`](https://developer.mozilla.org/en-US/docs/Web/API/Event/eventPhase) to determine which _phase_ the event is in. In this example, the listener is attached directly to the dialog, so we're interested in the `AT_TARGET` phase.

```js
// Listen for the sl-show event
dialog.addEventListener('sl-show', event => {
  if (event.eventPhase === Event.AT_TARGET) {
    console.log('The dialog has opened')
  }
});

// Listen for the sl-hide event
dialog.addEventListener('sl-hide', event => {
  if (event.eventPhase === Event.AT_TARGET) {
    console.log('The dialog has closed')
  }
});
```

[Try it on CodePen](https://codepen.io/claviska/pen/poKmxxQ)

## Why Not Use Unique Event Names?

It has been proposed numerous times that custom elements should emit events with unique names to avoid such confusion. For example, instead of emitting `sl-show` for multiple components, event names might named be based on their tag, e.g. `sl-dialog-show` and `sl-dropdown-show`.

I appreciate the idea, but this just isn't how events works. If you click a `<button>` you get a `click` event, not a `button-click` event. If you set focus to an `<input>`, you get a `focus` event, not an `input-focus` event.

For the sake of consistency with the platform, I consider this suggestion an anti-pattern that should be avoided in custom elements. Instead, developers must understand that many events bubble — including native ones — and they should be doing these checks any time elements are nested to avoid unexpected behaviors.

This problem isn't exclusive to custom elements.