Salesforce · · 15 min read

Events in LWC

Mastering component communication in LWC — custom events for child-to-parent communication, public methods and properties for parent-to-child, the Lightning Message Service for cross-component communication, and platform events via the empAPI.

Part 80: Events in LWC

Welcome back to the Salesforce series. In Part 79 we looked at component composition — how to nest LWC components inside each other and build a page out of smaller, reusable pieces. Composition answers the structural question: which components go where? But the moment you have more than one component on a page, a new question appears: how do those components talk to each other?

That is what this post is about. Communication between components is one of the most important things to understand in LWC development. Get it wrong and you end up with tightly coupled spaghetti. Get it right and your components stay independent, testable, and easy to maintain.

We are going to cover four communication patterns:

  1. Child to parent — Using custom events to send data upward through the component hierarchy.
  2. Parent to child — Using @api properties and public methods to send data downward.
  3. Across unrelated components — Using the Lightning Message Service to communicate between components that do not share a parent-child relationship.
  4. Platform events to LWC — Using the empAPI to subscribe to platform events and receive real-time updates from the server.

If you followed Part 76 where we covered JavaScript events, event propagation, and the CustomEvent constructor, that knowledge transfers directly here. LWC events are JavaScript events. The framework does not invent a new event system — it uses the one the browser already provides, with a few Salesforce-specific additions layered on top.

Let us get into it.


Events in LWC’s

Before we jump into the specific patterns, let us establish the big picture. In LWC, component communication follows a simple rule: data flows down, events flow up. This is the same principle React developers know, and it exists for a good reason. When a parent passes data to a child through a property, the flow is predictable. When a child needs to tell its parent something happened, it fires an event. The parent listens for that event and decides what to do.

This one-directional pattern keeps your components decoupled. The child does not need to know anything about the parent. It just fires an event and moves on. The parent does not need to reach into the child’s internals. It just listens for events and reads the data they carry.

When components are not in a parent-child relationship — say two sibling components on the same Lightning page — you need a different mechanism. That is where the Lightning Message Service comes in. And when you need to react to something happening on the server in real time, platform events and the empAPI are your tools.

Let us walk through each pattern with complete code examples.


Communicating from Child to Parent with Custom Events

This is the most common communication pattern in LWC. A child component fires a custom event, and the parent component handles it. The mechanics are straightforward because LWC custom events are just standard JavaScript CustomEvent objects, which we covered in Part 76.

The Child Component

The child is responsible for creating and dispatching the event. Let us build a simple notification item component that tells its parent when the user clicks a dismiss button.

notificationItem.html

<template>
    <div class="notification">
        <p>{message}</p>
        <lightning-button
            label="Dismiss"
            onclick={handleDismiss}>
        </lightning-button>
    </div>
</template>

notificationItem.js

import { LightningElement, api } from 'lwc';

export default class NotificationItem extends LightningElement {
    @api message;
    @api notificationId;

    handleDismiss() {
        const dismissEvent = new CustomEvent('dismiss', {
            detail: {
                id: this.notificationId
            }
        });
        this.dispatchEvent(dismissEvent);
    }
}

There are three things to pay attention to here. First, we create a new CustomEvent with the name 'dismiss'. Event names in LWC must be lowercase with no special characters — no underscores, no camelCase in the name itself. Second, we pass data through the detail property. This is the standard way to attach a payload to a custom event. Third, we call this.dispatchEvent() to fire the event. That is the same dispatchEvent method that exists on every DOM element — LWC did not invent it.

The Parent Component

The parent listens for the event using an on prefix added to the event name. Since the child fires an event named dismiss, the parent listens for ondismiss.

notificationList.html

<template>
    <h2>Notifications</h2>
    <template for:each={notifications} for:item="notif">
        <c-notification-item
            key={notif.id}
            message={notif.message}
            notification-id={notif.id}
            ondismiss={handleDismiss}>
        </c-notification-item>
    </template>
</template>

notificationList.js

import { LightningElement } from 'lwc';

export default class NotificationList extends LightningElement {
    notifications = [
        { id: '1', message: 'New lead assigned to you' },
        { id: '2', message: 'Case #4021 was escalated' },
        { id: '3', message: 'Approval request pending' }
    ];

    handleDismiss(event) {
        const dismissedId = event.detail.id;
        this.notifications = this.notifications.filter(
            notif => notif.id !== dismissedId
        );
    }
}

When the user clicks Dismiss on any notification item, the child fires the dismiss event with the notification’s ID in the detail. The parent’s handleDismiss method picks up that ID from event.detail.id and filters it out of the array. The template re-renders and the notification disappears.

Event Bubbling and Composed

By default, custom events in LWC do not bubble and are not composed. That means the event only reaches the immediate parent — it does not propagate further up the DOM tree. This is intentional. It keeps communication tight and predictable.

If you need an event to bubble through multiple levels of nesting, you can set bubbles: true and composed: true when creating the event:

const event = new CustomEvent('statuschange', {
    detail: { status: 'complete' },
    bubbles: true,
    composed: true
});
this.dispatchEvent(event);

Setting composed: true means the event will cross shadow DOM boundaries, which is necessary in LWC because each component has its own shadow root. Use this sparingly. Most of the time, the default non-bubbling behavior is what you want. If you find yourself needing bubbling events frequently, it is usually a sign that your component hierarchy needs rethinking.


Communicating from Parent to Child with @api

Going in the other direction — parent to child — uses the @api decorator we covered in Part 78. There are two mechanisms: public properties and public methods.

Public Properties

The simplest way for a parent to send data to a child is by setting a public property. The child declares a property with @api, and the parent sets its value in the template.

userBadge.html

<template>
    <div class="badge">
        <lightning-icon icon-name={iconName} size="small"></lightning-icon>
        <span class="badge-label">{label}</span>
    </div>
</template>

userBadge.js

import { LightningElement, api } from 'lwc';

export default class UserBadge extends LightningElement {
    @api label = 'Unknown';
    @api iconName = 'standard:user';
}

parentDashboard.html

<template>
    <c-user-badge label="Admin" icon-name="standard:user"></c-user-badge>
    <c-user-badge label={currentRole} icon-name={roleIcon}></c-user-badge>
</template>

The parent controls the child’s display by setting the label and icon-name attributes. When the parent’s currentRole property changes, the child’s label property updates automatically and the child re-renders. The child does not need to do anything — the framework handles the reactivity.

Remember the naming convention from Part 78: camelCase in JavaScript becomes kebab-case in HTML. So iconName in the child’s JS becomes icon-name in the parent’s template.

Public Methods

Sometimes you need the parent to trigger an action on the child, not just pass data. For this, you expose a public method on the child using @api.

modalDialog.html

<template>
    <template if:true={isOpen}>
        <section class="slds-modal slds-fade-in-open">
            <div class="slds-modal__container">
                <header class="slds-modal__header">
                    <h2>{title}</h2>
                </header>
                <div class="slds-modal__content">
                    <slot></slot>
                </div>
                <footer class="slds-modal__footer">
                    <lightning-button
                        label="Close"
                        onclick={handleClose}>
                    </lightning-button>
                </footer>
            </div>
        </section>
        <div class="slds-backdrop slds-backdrop_open"></div>
    </template>
</template>

modalDialog.js

import { LightningElement, api } from 'lwc';

export default class ModalDialog extends LightningElement {
    @api title = 'Dialog';
    isOpen = false;

    @api
    open() {
        this.isOpen = true;
    }

    @api
    close() {
        this.isOpen = false;
    }

    handleClose() {
        this.close();
        this.dispatchEvent(new CustomEvent('close'));
    }
}

parentPage.js

import { LightningElement } from 'lwc';

export default class ParentPage extends LightningElement {
    handleOpenModal() {
        this.template.querySelector('c-modal-dialog').open();
    }

    handleModalClose() {
        console.log('Modal was closed');
    }
}

parentPage.html

<template>
    <lightning-button label="Open Modal" onclick={handleOpenModal}></lightning-button>
    <c-modal-dialog title="Confirm Action" onclose={handleModalClose}>
        <p>Are you sure you want to proceed?</p>
    </c-modal-dialog>
</template>

The parent calls this.template.querySelector('c-modal-dialog').open() to invoke the child’s public method. Notice that the child also fires a close event when the modal is closed, which is a nice example of both patterns working together — the parent uses a public method to tell the child to open, and the child uses a custom event to tell the parent it closed.

One important rule: you should only call @api methods on child components after they have rendered. If you try to query a child component in the constructor or connectedCallback before the template has rendered, querySelector will return null.


Communicating Across Components via the Lightning Message Service

Custom events handle parent-child communication. But what about two components that sit on the same Lightning page with no direct relationship? Maybe a filter panel on the left side of the page needs to update a data table on the right side, and they do not share a parent component. This is where the Lightning Message Service, or LMS, comes in.

LMS uses a publish-subscribe pattern. One component publishes a message on a named channel, and any other component subscribed to that channel receives the message. The components do not need to know about each other at all.

Step 1: Create a Message Channel

Before you can publish or subscribe, you need a message channel. This is a metadata file you create in your Salesforce project.

Create the file at force-app/main/default/messageChannels/FiltersChanged.messageChannel-meta.xml:

<?xml version="1.0" encoding="UTF-8"?>
<LightningMessageChannel xmlns="http://soap.sforce.com/2006/04/metadata">
    <masterLabel>Filters Changed</masterLabel>
    <isExposed>true</isExposed>
    <description>Published when the user changes filter criteria</description>
    <lightningMessageFields>
        <fieldName>filterType</fieldName>
        <description>The type of filter that changed</description>
    </lightningMessageFields>
    <lightningMessageFields>
        <fieldName>filterValue</fieldName>
        <description>The new filter value</description>
    </lightningMessageFields>
</LightningMessageChannel>

This XML defines a channel called FiltersChanged with two fields: filterType and filterValue. You deploy this to your org like any other metadata.

Step 2: The Publisher Component

The publisher imports the message channel and uses publish from lightning/messageService.

filterPanel.js

import { LightningElement, wire } from 'lwc';
import { publish, MessageContext } from 'lightning/messageService';
import FILTERS_CHANGED from '@salesforce/messageChannel/FiltersChanged__c';

export default class FilterPanel extends LightningElement {
    @wire(MessageContext)
    messageContext;

    selectedStatus = '';

    handleStatusChange(event) {
        this.selectedStatus = event.detail.value;

        const message = {
            filterType: 'status',
            filterValue: this.selectedStatus
        };

        publish(this.messageContext, FILTERS_CHANGED, message);
    }
}

filterPanel.html

<template>
    <lightning-card title="Filters">
        <div class="slds-p-around_medium">
            <lightning-combobox
                label="Status"
                value={selectedStatus}
                options={statusOptions}
                onchange={handleStatusChange}>
            </lightning-combobox>
        </div>
    </lightning-card>
</template>

When the user selects a new status, the component publishes a message on the FiltersChanged channel with the filter type and value.

Step 3: The Subscriber Component

The subscriber imports the same message channel and uses subscribe to listen for messages.

dataTable.js

import { LightningElement, wire } from 'lwc';
import {
    subscribe,
    unsubscribe,
    MessageContext
} from 'lightning/messageService';
import FILTERS_CHANGED from '@salesforce/messageChannel/FiltersChanged__c';

export default class DataTable extends LightningElement {
    @wire(MessageContext)
    messageContext;

    activeFilter = 'All';
    subscription = null;

    connectedCallback() {
        this.subscribeToChannel();
    }

    disconnectedCallback() {
        this.unsubscribeFromChannel();
    }

    subscribeToChannel() {
        if (!this.subscription) {
            this.subscription = subscribe(
                this.messageContext,
                FILTERS_CHANGED,
                (message) => this.handleFilterChange(message)
            );
        }
    }

    unsubscribeFromChannel() {
        unsubscribe(this.subscription);
        this.subscription = null;
    }

    handleFilterChange(message) {
        if (message.filterType === 'status') {
            this.activeFilter = message.filterValue;
            this.refreshData();
        }
    }

    refreshData() {
        // Call Apex or use wire to reload data based on activeFilter
        console.log('Refreshing data with filter:', this.activeFilter);
    }
}

The subscriber sets up the subscription in connectedCallback and tears it down in disconnectedCallback. This is important — always clean up your subscriptions. If you do not unsubscribe, you can end up with memory leaks or ghost handlers that fire after the component has been removed from the page.

Notice that neither component imports or references the other. The filter panel publishes a message and does not care who receives it. The data table subscribes and does not care who published. This loose coupling is exactly what makes LMS powerful for cross-component communication on Lightning pages.

When to Use LMS

LMS is the right tool when two components on the same page need to communicate but do not have a parent-child relationship. It also works across different component types — an Aura component can publish a message that an LWC component subscribes to, and vice versa. This makes it useful during migration from Aura to LWC, where you might have a mix of both on the same page.

Do not use LMS for parent-child communication. Custom events and @api properties are simpler, more efficient, and easier to debug for that use case. Reach for LMS only when the simpler patterns cannot work.


Communicating to Your LWC via Platform Events and the empAPI

The communication patterns we have covered so far all happen within the browser. But what about when something changes on the server and you want your component to react immediately? That is the use case for platform events combined with the empAPI.

Platform events in Salesforce are part of the event-driven architecture. They use a publish-subscribe model backed by the CometD protocol. An Apex trigger, a flow, or an external system publishes a platform event, and any subscriber receives it in near real-time. The empAPI module in LWC lets your component subscribe to these events right from the browser.

Step 1: Create a Platform Event

In Salesforce Setup, navigate to Platform Events and create a new event. For this example, let us say we have a platform event called Case_Update__e with two custom fields:

  • Case_Id__c (Text)
  • Update_Type__c (Text)

This event gets published whenever a case is updated through an automated process.

Step 2: Subscribe in Your LWC

caseMonitor.js

import { LightningElement } from 'lwc';
import { subscribe, unsubscribe, onError } from 'lightning/empApi';

export default class CaseMonitor extends LightningElement {
    subscription = null;
    channelName = '/event/Case_Update__e';
    latestUpdate = '';

    connectedCallback() {
        this.registerErrorListener();
        this.handleSubscribe();
    }

    disconnectedCallback() {
        this.handleUnsubscribe();
    }

    handleSubscribe() {
        const callback = (response) => {
            const eventData = response.data.payload;
            this.latestUpdate =
                `Case ${eventData.Case_Id__c} - ${eventData.Update_Type__c}`;
            console.log('Platform event received:', eventData);
        };

        subscribe(this.channelName, -1, callback).then((response) => {
            this.subscription = response;
            console.log('Subscribed to channel:', this.channelName);
        });
    }

    handleUnsubscribe() {
        if (this.subscription) {
            unsubscribe(this.subscription, (response) => {
                console.log('Unsubscribed from channel:', response);
            });
        }
    }

    registerErrorListener() {
        onError((error) => {
            console.error('empAPI error:', JSON.stringify(error));
        });
    }
}

caseMonitor.html

<template>
    <lightning-card title="Case Monitor">
        <div class="slds-p-around_medium">
            <template if:true={latestUpdate}>
                <p>Latest update: {latestUpdate}</p>
            </template>
            <template if:false={latestUpdate}>
                <p>Listening for case updates...</p>
            </template>
        </div>
    </lightning-card>
</template>

Let us break down the key parts. The channelName follows the pattern /event/YourPlatformEvent__e. The -1 parameter in the subscribe call is the replay ID — passing -1 means you only want new events from this point forward. If you pass -2, you get all stored events within the retention window (24 hours by default).

The response.data.payload object contains the platform event’s fields. You access them using their API names just like you would in Apex.

The registerErrorListener method sets up a global error handler for the empAPI. This catches things like connection drops or authentication issues. Always register an error listener — without it, empAPI failures happen silently and your component just stops receiving events with no indication of why.

Step 3: Publishing from Apex

For completeness, here is how you might publish the platform event from Apex:

Case_Update__e event = new Case_Update__e();
event.Case_Id__c = caseRecord.Id;
event.Update_Type__c = 'Status Changed';

Database.SaveResult result = EventBus.publish(event);

if (result.isSuccess()) {
    System.debug('Event published successfully');
}

When this Apex code runs — whether from a trigger, a scheduled job, or a flow-invocable method — any LWC on a user’s screen that is subscribed to /event/Case_Update__e will receive the event in near real-time.

When to Use the empAPI

Use the empAPI when you need live updates pushed from the server to the client. Common use cases include monitoring record changes, tracking long-running asynchronous processes, displaying real-time notifications, and building dashboards that update without the user refreshing the page.

Keep in mind that platform event subscriptions use CometD long-polling under the hood, which means there is a limit on concurrent subscriptions per user. For most orgs this is not an issue, but if you have many components all subscribing to different events, be aware of the limits.


Choosing the Right Pattern

Here is a quick reference for when to use each communication pattern:

ScenarioPattern
Child tells parent something happenedCustom event with dispatchEvent
Parent passes data to child@api property
Parent triggers action on child@api method via querySelector
Siblings or unrelated components on same pageLightning Message Service
Server pushes real-time updates to clientempAPI with platform events

Start with the simplest pattern that works. Custom events and @api properties should cover the majority of your use cases. Reach for LMS when you need cross-component communication without a shared parent. And bring in the empAPI when you need real-time server-to-client updates.


Section Notes

This post covered the four main communication patterns in LWC. Custom events handle the child-to-parent direction and are the most common pattern you will use. Public properties and methods via @api handle parent-to-child. The Lightning Message Service enables communication between unrelated components using a publish-subscribe model with message channels. And the empAPI brings server-side platform events into your components for real-time updates.

The key principles to remember are: data flows down through properties, events flow up through dispatchEvent, and when neither direction works because the components are unrelated, LMS acts as a decoupled message bus. For server-to-client communication, platform events and the empAPI give you a push-based model that avoids polling.

In the next post, we will look at wiring your components to Salesforce data using the wire service and Lightning Data Service, which builds directly on the communication patterns we covered here. You will see how @wire connects your component to Apex methods and standard data operations, completing the picture of how LWC components interact with the platform. See you there.