stave.dev

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.

Event dispatched
inside shadow DOM
Shadow Boundary
composed: true?
composed: false
Stops here
  
composed: true
Bubbles to document
Always Use Both Flags

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);
}
Cleanup Is Mandatory

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)