Custom Events
CustomEvent, detail payloads, bubbles and composed — decoupling components without a global event bus mess.
- 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.”
Related
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
- Event DelegationHandle many elements with one listener on a parent — bubbling, currentTarget vs target, and when not to delegate.
- Capture, Bubble, once, and passiveEvent propagation phases, addEventListener options — capture, once, passive, and signal — and when each fixes a real bug.
- classList and datasetToggle CSS classes with classList and read data-* attributes via dataset — tokens, naming, and DOM performance notes.
- client, offset, and scroll DimensionsclientWidth vs offsetWidth vs scrollWidth — borders, scrollbars, and which box measurement to use for layout math.
- contentEditable PitfallsWhy contentEditable is hard: browser HTML mess, carets, sanitization, undo, and when to pick a real editor framework.