HTML custom events: send a payload and let a parent cancel an action

Create an application event with new CustomEvent(name, { detail }), dispatch it on an element, and read the payload in the receiving listener. For a cancelable request, set cancelable: true and perform the requested change only when dispatchEvent() returns true.

An original queue example has a Remove button inside a record row. The row knows how to request removal. Its containing board knows which records are locked. A small “before remove” event lets those responsibilities meet without passing the button implementation into the board.

Define what the event promises

Use queue:before-remove as the exact event name and { recordId: string } as the payload. A listener can cancel synchronously with preventDefault(). No listener is required for an unlocked row to proceed.

This complete example starts with a locked record:

<section id="board">
  <article id="row" data-record-id="R-17">
    <p>R-17: Review shipment</p>
    <button type="button" id="remove">Remove</button>
  </article>
</section>
<p id="status" role="status"></p>
<script>
const board = document.querySelector('#board');
const row = document.querySelector('#row');
const status = document.querySelector('#status');
const lockedIds = new Set(['R-17']);

board.addEventListener('queue:before-remove', event => {
  if (lockedIds.has(event.detail.recordId)) {
    event.preventDefault();
    status.textContent = 'R-17 is locked; row kept.';
  }
});

document.querySelector('#remove').addEventListener('click', () => {
  const allowed = row.dispatchEvent(new CustomEvent(
    'queue:before-remove', {
      detail: { recordId: row.dataset.recordId },
      bubbles: true,
      cancelable: true
    }
  ));
  if (allowed) {
    row.remove();
    status.textContent = 'Row removed from this page.';
  }
});
</script>

The first click keeps the row. In this exercise, run lockedIds.delete('R-17') in the console and click again; the row disappears. This only changes the local page. A production deletion still needs its authorized persistence operation.

Check the three constructor choices

detail carries the record ID. Unlike a click count in a UIEvent, it is an application-defined value; keep its shape documented. The CustomEvent constructor reference, checked October 24, 2026, defines that payload field and event options.

bubbles: true lets the board's ordinary bubbling listener receive a request dispatched on the row. Without it, listen directly on the dispatch target or deliberately use capture where appropriate. cancelable: true lets preventDefault() affect the result. Omitting it makes the cancellation request ineffective.

The dispatchEvent reference specifies synchronous listener execution and the cancellation return value. stopPropagation() stops travel through the tree; it does not serve as the cancellation contract here.

Cross a shadow boundary deliberately

If a component dispatches from an element inside its shadow root and a page ancestor must receive the event, set composed: true as well as the appropriate bubbling behavior. The composed property controls that boundary. A public event dispatched on the host itself already starts outside its own shadow tree.

Outside listeners can see a retargeted event.target. Supply the stable record ID in detail instead of requiring callers to traverse private markup. The component guide explains the host and shadow tree; the native event guide covers ordinary click handling.

Keep asynchronous approval out of the synchronous gate

An async listener cannot pause dispatchEvent() while it awaits a server. By the time the awaited result arrives, the caller may already have removed the row. If approval needs a network request, use an explicit async removal function that awaits permission and reports its outcome, then emit a separate notification after success. Keep the small cancelable event for decisions the receiving code can make during dispatch.

Keep reading