Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

Briefly: down the tree use public properties, up the tree use a CustomEvent, and sideways use the Lightning Message Service. Three mechanisms, and the mistake that causes most of the confusion is reaching for the third when the first two would have worked, because a message channel has no shape and nothing tells you who is listening.

Which way is the data goingThree mechanisms by direction, and the fourth option that removes the need for any of them.Parent to child: @apiA public property the parent sets in its template. camelCase in JavaScript becomes kebab-case inmarkup, and a property that never arrives is usually that. Never mutate it in the child.Child to parent: CustomEventThe child announces; it does not know who listens. Event names must be lowercase with no hyphens orthe on-handler never binds, and it fails silently.Sideways: Lightning Message ServiceUnrelated components, or an LWC talking to Aura or Visualforce. A broadcast with no contract:nobody has to subscribe, and there is no error when nobody does.Often: no communication at allTwo components on the same record via Lightning Data Service share a cache. One saves, theother refreshes, neither knows the other exists.Reaching for a message channel when a property would do is the common mistake. Check whether LDS alreadysolves it before adding a channel nobody can see subscribers for.
Three mechanisms by direction, and the fourth option that removes the need for any of them.

Parent to child: @api

A property marked @api is public. The parent sets it in its template, and the child re-renders when it changes.

// child.js
export default class Child extends LightningElement {
    @api recordId;
    @api label = 'Save';
}
<!-- parent.html -->
<c-child record-id={currentId} label="Confirm"></c-child>

Note the case change: recordId in JavaScript, record-id in markup. HTML attribute names are case-insensitive, so the framework translates camelCase to kebab-case, and a property that quietly never arrives is usually this.

Two rules people break. Do not mutate an @api property inside the child — it belongs to the parent, and changing it produces behaviour that depends on render order. And if the property is an object or array, the child must not mutate its contents either, because the parent holds the same reference.

A parent can also call a child’s @api method directly through this.template.querySelector('c-child').focusFirstField(). That is legitimate for imperative things like focus or validation, and wrong for anything that is really state.

Child to parent: CustomEvent

The child announces that something happened. It does not know or care who is listening.

// child.js
handleSelect(event) {
    this.dispatchEvent(new CustomEvent('rowselect', {
        detail: { recordId: event.currentTarget.dataset.id }
    }));
}
<!-- parent.html -->
<c-child onrowselect={handleRowSelect}></c-child>

The event name must be lowercase with no hyphens, or the on handler in the template will not bind. rowselect works, rowSelect and row-select do not, and this fails silently.

Keep detail to primitives. In Lightning Web Security an object passed through detail may be given to the listener as a copy, so a listener mutating it is a bug that appears in one context and not another. Send an ID and let the parent fetch what it needs.

Events do not bubble out of the shadow boundary by default, and that default is correct. Set bubbles: true, composed: true only when you have a reason you can articulate, because a composed event escapes the component entirely and can be caught by things you did not write.

Sideways: Lightning Message Service

Two components on a page that are not related, or an LWC and an Aura component, or a Visualforce page. That is what a message channel is for, and it is the only thing it is for.

import { publish, MessageContext } from 'lightning/messageService';
import ORDER_CHANNEL from '@salesforce/messageChannel/Order_Selected__c';

@wire(MessageContext) messageContext;

publish(this.messageContext, ORDER_CHANNEL, { recordId: this.selectedId });

The cost is that the channel is a broadcast with no contract. Nobody has to subscribe, and there is no error when nobody does. Debugging “the other component did not update” means checking three separate things: that the publish ran, that the subscriber exists on the page, and that the payload matched what the subscriber expected.

Always unsubscribe in disconnectedCallback. A subscription that outlives its component is a leak, and on a page where users move between records it accumulates.

The fourth option nobody mentions

Two components showing the same record do not need to talk at all. If both use the Lightning Data Service (getRecord, lightning-record-form, or updateRecord) the cache updates every consumer. One component saves, the other refreshes, and neither knows the other exists. Reach for a message channel only after checking that LDS does not already solve it, which is the same instinct that keeps components simple in stale data after a save.

Frequently asked

Can a child talk to a grandparent? Only by each layer re-dispatching, or by a composed event, or by a message channel. Re-dispatching is verbose and explicit. Pick it when the tree is shallow.

Why is my handler not firing? Almost always the event name has a capital or a hyphen, or the listener is on the wrong element.

Should I use a pub/sub library? No. That pattern predates the Lightning Message Service and the platform now supports the same thing properly.

Does any of this work across tabs? No. A message channel is scoped to one Lightning page in one browser tab.