Custom Event Names and the Bubbling Problem

The topic of custom element event names comes up every now and then, especially from Shoelace users who get confused when events of the same name are emitted from different components.

Take <sl-details>, <sl-dialog>, and <sl-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.

<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

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, 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.

Alas, this is also a source of confusion.

Fortunately, there are a number of 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.

// 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

Checking the Current Target #

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

// 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

Checking the Event Phase #

Here's yet 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 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.

// 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

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.