Events & Communication
Custom events, composed dispatch across shadow boundaries, and patterns for parent-child communication.
Custom Events
Components communicate outward by dispatching CustomEvent instances. The key is getting the event options right so it actually reaches listeners outside the shadow DOM:
this.dispatchEvent(new CustomEvent("item-selected", {
bubbles: true, // propagates up the DOM tree
composed: true, // crosses shadow DOM boundaries
detail: {
id: this._selectedId,
label: this._selectedLabel
}
}));
Why composed: true Matters
Events dispatched inside a shadow root stop at the shadow boundary by default. Without composed: true, parent elements will never see the event.
inside shadow DOM
For events that need to reach parent components or page-level listeners, always set both bubbles: true and composed: true. Missing either one means the event won't reach its intended audience.
Event Delegation
For components with many interactive children (lists, tables, grids), attach a single listener to a container instead of one per item:
connectedCallback() {
super.connectedCallback();
this._list = this.shadowRoot.querySelector(".item-list");
// One listener handles all items
this._list.addEventListener("click", (e) => {
const item = e.target.closest("[data-id]");
if (!item) return;
this.dispatchEvent(new CustomEvent("item-click", {
bubbles: true,
composed: true,
detail: { id: item.dataset.id }
}));
});
}
Parent to Child Communication
Parents communicate down to children in three ways:
1. Attributes and Properties
The simplest approach — set an attribute or property, and the child reacts through attributeChangedCallback or a custom setter:
// Via attribute (triggers attributeChangedCallback)
child.setAttribute("status", "active");
// Via observable property (triggers setAttribute internally)
child.status = "active";
// Via non-observable property (direct, no callback)
child.data = { items: [...] };
2. Method Calls
Parent calls a public method on the child directly:
const toast = document.querySelector("shadow-toast");
toast.show("File saved successfully", "success");
const calculator = document.querySelector("shadow-calculator");
calculator.reset();
3. Slotted Content
Parent provides content through slots — the child's template defines where it appears:
<my-dialog>
<h2 slot="title">Confirm Action</h2>
<p slot="body">Are you sure you want to proceed?</p>
<button slot="actions">Cancel</button>
<button slot="actions">Confirm</button>
</my-dialog>
Child to Parent Communication
Children communicate up to parents through custom events. The parent listens, the child dispatches:
// In the child component
handleSelection(item) {
this.dispatchEvent(new CustomEvent("selection-changed", {
bubbles: true,
composed: true,
detail: { item }
}));
}
// Parent listens for the event
const picker = document.querySelector("item-picker");
picker.addEventListener("selection-changed", (e) => {
console.log("Selected:", e.detail.item);
updateDisplay(e.detail.item);
});
Document-Level Event Bus
For communication between components that don't have a parent-child relationship, use the document as a global event bus:
// Component A: dispatch a global event
document.dispatchEvent(new CustomEvent("theme-changed", {
detail: { theme: "dark" }
}));
// Component B: listen for the global event
connectedCallback() {
super.connectedCallback();
this._onThemeChanged = (e) => this.applyTheme(e.detail.theme);
document.addEventListener("theme-changed", this._onThemeChanged);
}
disconnectedCallback() {
// Always clean up document-level listeners
document.removeEventListener("theme-changed", this._onThemeChanged);
}
Document-level listeners outlive your component. If the component is removed from the DOM without cleaning up, you get a memory leak and ghost handlers. Always remove them in disconnectedCallback().
Exposing a Global API
Some components (like toasts or notifications) benefit from a global function that any code can call. The pattern is to register a function on window during connectedCallback:
class ShadowToast extends ShadowComponent {
connectedCallback() {
super.connectedCallback();
this.container = this.shadowRoot.querySelector(".toast-container");
// Make globally available
if (!window.showToast) {
window.showToast = (message, type = "success") => {
this.show(message, type);
};
}
}
show(message, type = "success") {
// Create and animate a toast notification
}
}
Now any code on the page can call showToast("Saved!", "success") without needing a reference to the component.
Communication Summary
| Direction | Mechanism | Best For |
|---|---|---|
| Parent → Child | Attributes / Properties | Reactive data flow, configuration |
| Parent → Child | Method calls | Imperative actions (show, hide, reset) |
| Parent → Child | Slotted content | Structural composition, server-rendered content |
| Child → Parent | Custom events | User interactions, state changes |
| Sibling ↔ Sibling | Document event bus | Cross-component coordination |
| Any → Component | Global API (window.*) |
Utility components (toast, modal) |