ESC

Type to search the knowledge base.

Custom Events

CustomEvent, detail payloads, bubbles and composed — decoupling components without a global event bus mess.

intermediate3 min read
  • javascript
  • custom-events
  • dom
  • events

Native events cover clicks and inputs. Custom events let your widgets announce domain things: cart:add, tab-change, rate-limit. They ride the same DOM propagation rules as click — which means you can bubble signals up without importing a shared state library for every tiny widget.

Dispatch and listen

const list = document.querySelector('#product-list');

list.addEventListener('product-select', (e) => {
  console.log(e.detail.id);
});

// child or external code:
list.dispatchEvent(
  new CustomEvent('product-select', {
    detail: { id: 'sku_1', qty: 1 },
    bubbles: true,
    cancelable: true,
  }),
);

detail holds your payload. Prefer plain data (POJOs) over class instances that won’t clone well across boundaries.

bubbles, composed, cancelable

Option Effect
bubbles: true propagates up ancestors
composed: true crosses shadow DOM boundary
cancelable: true preventDefault() can mark cancelled
// inside shadow DOM component
this.dispatchEvent(
  new CustomEvent('change', {
    detail: { value: this.value },
    bubbles: true,
    composed: true,
  }),
);

// host page listens on the element
document.querySelector('x-input').addEventListener('change', handler);

Without composed: true, listeners outside the shadow root never see the event.

cancelable for veto patterns

const ev = new CustomEvent('item-remove', {
  detail: { id },
  bubbles: true,
  cancelable: true,
});

const allowed = node.dispatchEvent(ev);
// dispatchEvent returns false if preventDefault was called
if (!allowed) return;

actuallyRemove(id);

Parents can veto destructive actions without tight coupling.

Namespacing

// avoid colliding with native "submit" / "change" unless intentional
el.dispatchEvent(new CustomEvent('cart:add', { detail, bubbles: true }));

If you intentionally mirror native names (input, change) on a custom element, document it — framework interop often expects that.

vs EventEmitter / pub-sub

DOM CustomEvent App EventEmitter
Tied to a node tree Free-floating topics
Auto GC with nodes Must unsubscribe manually
Great for components Great for app-wide services

Don’t replace Redux with window custom events. Do use them for leaf component → parent communication and design-system hooks.

// anti-pattern: global bus via window for everything
window.dispatchEvent(new CustomEvent('user', { detail: user }));
// hard to debug, easy to leak listeners

TypeScript tip

declare global {
  interface HTMLElementEventMap {
    'product-select': CustomEvent<{ id: string; qty: number }>;
  }
}

Interview answer

“CustomEvent carries a detail payload and optional bubbles/composed/cancelable flags. composed matters for Shadow DOM. I use bubbling custom events for component output, cancelable events for veto flows, and a dedicated emitter for app-wide concerns. I avoid a window-level event soup.”

Re-dispatch and composedPath

el.addEventListener('product-select', (e) => {
  console.log(e.composedPath()); // full retarget path across shadow
});

// wrap native events when needed
input.addEventListener('change', (e) => {
  e.currentTarget.dispatchEvent(
    new CustomEvent('field-change', {
      detail: { name: e.currentTarget.name, value: e.currentTarget.value },
      bubbles: true,
    }),
  );
});

event.detail is free-form — document the shape next to the component. For TypeScript consumers, augment HTMLElementEventMap. Avoid putting non-cloneable objects in detail if the event might cross worker or postMessage boundaries later.

Testing custom events

function fireSelect(el, detail) {
  el.dispatchEvent(
    new CustomEvent('product-select', { detail, bubbles: true, composed: true }),
  );
}

// in tests
const seen = [];
host.addEventListener('product-select', (e) => seen.push(e.detail));
fireSelect(child, { id: '1' });
assert.deepEqual(seen, [{ id: '1' }]);

Assert on detail and bubbling, not on internal method calls — keeps tests at the component boundary.

Further reading

Related guides