SfSkills Domains Docs GitHub

Home / lwc / lwc-custom-event-patterns

lwc-custom-event-patterns

CustomEvent design — bubbles/composed/cancelable, detail payloads, naming, and why an event is not reaching the parent. Triggers: 'event not reaching parent', 'composed shadow DOM', 'child component cannot tell the parent something changed', 'how does a child lwc notify its parent'. NOT for parent-to-child @api — use lwc/component-communication. NOT for sibling fan-out via Lightning Message Service — use lwc/message-channel-patterns.

Open the skill folder on GitHub


LWC Custom Event Patterns

Activate this skill when a Lightning Web Component needs to emit an event upward — to its parent, an ancestor, an Aura host, or out of a slot — and the question is which combination of bubbles, composed, and cancelable to use, what the detail payload should look like, and how to name the event so it actually fires. The four flag combinations behave very differently across light DOM, shadow DOM, and Aura host boundaries, and the wrong combination silently drops the event with no error.

This is NOT the place for parent-to-child data flow (use @api properties or @api methods — see lwc/component-communication), and it is NOT the right hammer for sibling-to-sibling broadcast across unrelated subtrees (use Lightning Message Service — see lwc/lightning-message-service). Custom events are for child → ancestor intent signalling within a single component tree.


Before Starting

Gather this context before working on anything in this domain:


Core Concepts

Every LWC Lives In Its Own Shadow Root

A Lightning Web Component renders into a closed shadow root. DOM events naturally stop at shadow boundaries unless the event is composed. This is enforced by the browser, not by Salesforce — composed: false events are retargeted so they appear to originate from the host element, but they cannot be observed from outside that host's shadow tree.

The practical consequence: if <c-grandparent> contains <c-parent> which contains <c-child>, an event dispatched from <c-child> with composed: false is invisible to <c-grandparent>'s listener. The listener never fires. There is no console warning. The event simply doesn't reach the listener.

The bubbles + composed Truth Table

The two flags interact, and their joint behaviour is the single most-asked LWC interview question:

bubblescomposedWhere the event travels
falsefalseDefault. Stays on the dispatching element. Only listeners on that exact element fire.
truefalseBubbles up the DOM tree but stops at the nearest shadow root boundary. A direct parent inside the same shadow tree hears it; a grandparent across a shadow boundary does not.
falsetrueCrosses shadow boundaries but does not bubble — only the host elements along the composed path see it. Rare; usually a mistake.
truetrueBubbles up the DOM and crosses every shadow boundary along the way. The Aura host, the document, and any ancestor LWC can observe it.

Salesforce's official guidance (Lightning Web Components Developer Guide → Communicate with Events): use bubbles: false, composed: false (the default) for most events, and only opt into bubbles: true, composed: true when the event genuinely needs to escape the component to reach an Aura container or a non-direct ancestor.

Event Naming Rules Are Non-Negotiable

dispatchEvent(new CustomEvent(...)) Is The Only Idiom

The legacy fireEvent helper from earlier pubsub.js examples is deprecated and unsupported in modern LWC. The only sanctioned dispatch is:

this.dispatchEvent(new CustomEvent('rowselect', {
    detail: { recordId },
    bubbles: true,      // optional, defaults to false
    composed: true,     // optional, defaults to false
    cancelable: true,   // optional, defaults to false
}));

Bare dispatchEvent('rowselect') and dispatchEvent({ type: 'rowselect' }) do not work — the argument must be a CustomEvent (or other Event) instance constructed with new.

event.target Versus event.currentTarget

If a parent reads event.target.dataset.recordId to identify which row was clicked, retargeting can give them the wrong element. event.currentTarget.dataset.recordId reads from the listener-bound element directly and is always correct in handler code.


Common Patterns

Pattern A — Direct Parent Listening For A Child Event

When to use: parent owns the child in markup; the event does not need to escape to an Aura host.

How it works:

// childCmp.js
this.dispatchEvent(new CustomEvent('rowselect', {
    detail: { recordId: this.recordId },
    // bubbles + composed left at default (false). The direct parent is
    // inside the same shadow tree and will receive the event.
}));
<!-- parentCmp.html -->
<c-child-cmp onrowselect={handleRowSelect}></c-child-cmp>

Why not bubbles: true, composed: true: the receiver is one level up in the same shadow tree, so neither flag is required. Adding them weakens encapsulation and lets the event leak into Aura or the page chrome.

Pattern B — Cross-Boundary Event To An Aura Host Or Distant Ancestor

When to use: an Aura wrapper or a non-direct ancestor LWC must hear the event.

How it works:

// deeplyNestedChild.js
this.dispatchEvent(new CustomEvent('recordsavefailure', {
    detail: { recordId, errorMessage },
    bubbles: true,
    composed: true,
}));
<!-- auraHost.cmp -->
<c:deeplyNestedChild onrecordsavefailure="{!c.handleFailure}" />

Why not omit composed: without composed: true, the event never escapes the LWC's shadow root and the Aura onrecordsavefailure handler never fires. The Aura host's controller method is never invoked, the user sees nothing, and there is no error in the console. Forgetting composed: true is the single most common LWC integration bug.

Pattern C — Cancelable Event With An Ancestor Veto

When to use: the parent must be allowed to prevent a default action — for example, "before close" semantics in a modal.

How it works:

// modalCmp.js
const closeEvent = new CustomEvent('beforeclose', {
    detail: { reason: 'user-clicked-x' },
    cancelable: true,
});
this.dispatchEvent(closeEvent);
if (closeEvent.defaultPrevented) {
    return; // ancestor vetoed the close
}
this.actuallyClose();
// parentCmp.js
handleBeforeClose(event) {
    if (this.hasUnsavedChanges) {
        event.preventDefault();
    }
}

Why not skip the defaultPrevented check: cancelable: true only opens the door to preventDefault(); the dispatcher must actually check event.defaultPrevented afterward, otherwise the veto is ignored.

Pattern D — Detail Payload As An Immutable Snapshot

When to use: any time the event detail contains arrays or objects the dispatcher still owns.

How it works:

// dispatcher
this.dispatchEvent(new CustomEvent('selectionchange', {
    detail: {
        selectedIds: Object.freeze([...this.selectedIds]),
        // OR: structuredClone(this.selectedIds) for nested objects
    },
    bubbles: true,
    composed: true,
}));

Why not pass this.selectedIds directly: parents can push into the array, which mutates the dispatcher's internal state through the shared reference. Object.freeze plus a shallow copy gives the parent a snapshot they cannot mutate; structuredClone is needed when the payload contains nested objects.


Decision Guidance

Choose the communication mechanism by direction and reach, not by feel:

SituationRecommended approachReason
Parent needs to push data down into a child@api property on the childDeclarative, reactive, clearly typed; no event ceremony
Parent needs to call a child imperative method@api method invoked via template.querySelectorDirect contract; no event needed
Child must signal intent up to a direct parent in the same shadow treenew CustomEvent('name') with bubbles: false, composed: falseDefault-safe; minimum surface area
Deeply nested LWC must reach a higher LWC ancestornew CustomEvent('name', { bubbles: true, composed: true })Only composed: true crosses shadow boundaries
LWC inside an Aura wrapper must signal the Aura controllernew CustomEvent('name', { bubbles: true, composed: true })Aura host is across at least one shadow boundary
Parent must be able to veto a child actionnew CustomEvent('name', { cancelable: true }) + event.preventDefault() + if (event.defaultPrevented) return;Standard DOM pattern; LWC supports it natively
Sibling LWCs in unrelated subtrees must communicateLightning Message Service (lwc/lightning-message-service)Custom events cannot reach across detached trees
Cross-page or cross-region broadcastLightning Message ServiceLMS is the only sanctioned channel for unrelated regions
Legacy pubsub.js from older docsDo not use — migrate to LMS or custom eventsDeprecated; no longer recommended by Salesforce

  1. Locate the listener. Identify exactly which element will call addEventListener (or declare on<eventname> in markup). Note whether it is in the same shadow tree, in an ancestor LWC, in an Aura host, or in a different region.
  2. Choose the flag combination from the truth table. Default to bubbles: false, composed: false. Promote to bubbles: true, composed: true only when the listener is across a shadow boundary, and document why.
  3. Name the event correctly. Single lowercase word, no on prefix, no hyphens, no camelCase. Prefer a verb phrase concatenated as one token.
  4. Design the detail payload defensively. Snapshot or clone arrays and objects you still own. Document the payload shape (keys, types, optionality) in the component's events catalog using templates/lwc-custom-event-patterns-template.md.
  5. Decide on cancelability. If a parent must be able to veto, set cancelable: true AND check event.defaultPrevented after dispatch.
  6. Wire the listener. Use event.currentTarget (not event.target) to read the element you bound the listener to; event.target may be retargeted to a host element.
  7. Validate with the checker. Run python3 skills/lwc/lwc-custom-event-patterns/scripts/check_lwc_custom_event_patterns.py <path-to-lwc-folder> and resolve any flagged dispatches.

Review Checklist


Salesforce-Specific Gotchas

Non-obvious LWC platform behaviours that cause real production problems:

  1. composed: false silently drops events at the shadow boundary. No console warning, no error log, the listener simply never fires. Forgetting this flag on a deeply nested event is the most common LWC bug, and it is invisible until QA notices the missing behaviour.
  2. Hyphenated event names break the declarative on<name> attribute. Dispatching 'selection-change' while listening for onselectionchange matches nothing — the runtime treats selection-change and selectionchange as different event types. The dispatcher fires; nobody hears.
  3. event.target is retargeted across shadow boundaries. A parent reading event.target.dataset.id for a bubbled+composed event sees the host LWC element, not the inner row that was clicked. Use event.currentTarget, or read the data from event.detail on the dispatcher side.
  4. Mutating event.detail in the parent leaks back into the child. Arrays and objects in detail are shared references. A parent that does event.detail.items.push(x) corrupts the child's internal state. Snapshot or freeze before dispatch.
  5. stopPropagation() does not stop sibling listeners on the same element — stopImmediatePropagation() does. If a row component has two listeners for click, calling stopPropagation() in the first one still lets the second fire. Use stopImmediatePropagation() to stop both.

Output Artifacts

ArtifactDescription
Event dispatch snippetA new CustomEvent(name, { detail, bubbles, composed, cancelable }) call with the right flag combination for the listener location
Events catalog entryA row in the component's events.md (using templates/lwc-custom-event-patterns-template.md) documenting event name, payload shape, flags, and the intended listener
Listener wiringThe on<eventname> attribute or addEventListener call, plus event.currentTarget access pattern
Checker runscripts/check_lwc_custom_event_patterns.py clean exit on the touched LWC folder

Package files