Component Composition in LWC – Parent-Child Communication
Learn how to build composable Lightning Web Components by passing data from parent to child using @api properties, and communicating back from child to parent using custom events — the two core pillars of LWC component composition.
1 What is Component Composition?
Component composition is the pattern of building complex UIs by nesting smaller, focused components inside each other. In LWC, a parent component owns and controls the overall page or feature, while child components handle specific, reusable pieces of UI.
This approach follows the single responsibility principle — each component does one thing well, and parent components wire them together.
owns state, coordinates children
c-product-card
c-order-form
c-notification
LWC has two mechanisms for parent-child communication:
• Parent → Child: Pass data down via @api public properties (like HTML attributes).
• Child → Parent: Send data up via CustomEvent (dispatched from child, handled in parent).
@api. Children should never modify a parent's property directly — they must dispatch an event to request a change.
2 Parent to Child — Passing Data with @api
To expose a property on a child component so the parent can set it, decorate it with @api. The parent then passes the value as an HTML attribute on the child component tag.
@api.
<template>
<div style="border:1px solid #cbd5e1; border-radius:10px;
padding:16px; background:#fff; max-width:260px;">
<h3 style="color:#032D60; font-size:16px; margin-bottom:6px;">
{productName}
</h3>
<p style="color:#475569; font-size:14px;">
Category: {category}
</p>
<p style="font-size:20px; font-weight:700; color:#0070D2; margin-top:8px;">
${price}
</p>
</div>
</template>
import { LightningElement, api } from 'lwc';
export default class ProductCard extends LightningElement {
@api productName; // ← exposed to parent
@api category; // ← exposed to parent
@api price; // ← exposed to parent
}
<template>
<lightning-card title="Product Catalog" icon-name="utility:product_consumed">
<div class="slds-p-around_medium"
style="display:flex; gap:16px; flex-wrap:wrap;">
<!-- Passing data DOWN to child via @api properties -->
<c-product-card
product-name="Laptop Pro X"
category="Electronics"
price="1299">
</c-product-card>
<c-product-card
product-name="Wireless Mouse"
category="Accessories"
price="49">
</c-product-card>
<c-product-card
product-name="USB-C Hub"
category="Accessories"
price="79">
</c-product-card>
</div>
</lightning-card>
</template>
• @api productName in the child exposes the property — the parent sets it as product-name="..." (camelCase becomes kebab-case in HTML).
• @api properties are reactive — when the parent updates the value, the child re-renders automatically.
• You can pass strings, numbers, booleans, objects, and arrays via @api.
productName) become kebab-case HTML attributes (e.g., product-name). This is standard HTML attribute convention.
3 Passing Dynamic Data from Parent JS
In real apps, the parent usually stores data in a JavaScript array and renders child components using for:each. Each child receives its own data slice via @api.
<template>
<lightning-card title="Dynamic Product List" icon-name="utility:list">
<div class="slds-p-around_medium"
style="display:flex; gap:16px; flex-wrap:wrap;">
<template for:each={products} for:item="prod">
<c-product-card
key={prod.id}
product-name={prod.name}
category={prod.category}
price={prod.price}>
</c-product-card>
</template>
</div>
</lightning-card>
</template>
import { LightningElement } from 'lwc';
export default class DynamicProductList extends LightningElement {
products = [
{ id: 'p1', name: 'Laptop Pro X', category: 'Electronics', price: 1299 },
{ id: 'p2', name: 'Wireless Mouse', category: 'Accessories', price: 49 },
{ id: 'p3', name: 'USB-C Hub', category: 'Accessories', price: 79 },
{ id: 'p4', name: 'Monitor 4K', category: 'Electronics', price: 599 },
{ id: 'p5', name: 'Mechanical KB', category: 'Peripherals', price: 159 }
];
}
• The parent loops over products and renders one c-product-card for each item.
• Each child automatically gets its own isolated set of @api property values.
• When products array changes in the parent, all child cards re-render with fresh data.
4 Child to Parent — Custom Events
When a child needs to notify the parent of an action (button click, form submit, selection change), it dispatches a custom event. The parent listens for this event using an on<eventname> handler.
this.dispatchEvent(new CustomEvent('eventname', { detail: payload }))📌 Parent listens:
<c-child oneventname={handleEvent}>
CustomEvent with an optional detail payload carrying the relevant data.bubbles: true is set) or is caught directly by the immediate parent's event handler.event.detail, and updates its own state — triggering a re-render.<template>
<div style="border:1px solid #cbd5e1; border-radius:10px;
padding:16px; background:#fff; max-width:260px;">
<h3 style="color:#032D60; margin-bottom:6px;">{productName}</h3>
<p style="color:#0070D2; font-weight:700;">${price}</p>
<lightning-button
label="Add to Cart"
variant="brand"
onclick={handleAddToCart}
class="slds-m-top_small">
</lightning-button>
</div>
</template>
import { LightningElement, api } from 'lwc';
export default class AddToCartButton extends LightningElement {
@api productName;
@api productId;
@api price;
handleAddToCart() {
// Dispatch custom event with payload → parent will catch this
const event = new CustomEvent('addtocart', {
detail: {
id: this.productId,
name: this.productName,
price: this.price
}
});
this.dispatchEvent(event);
}
}
<template>
<lightning-card title="Shop" icon-name="utility:cart">
<div class="slds-p-around_medium">
<p style="margin-bottom:12px; font-weight:600;">
🛒 Cart Items: {cartCount}
</p>
<!-- Listen to the child's "addtocart" event -->
<template for:each={products} for:item="prod">
<c-add-to-cart-button
key={prod.id}
product-id={prod.id}
product-name={prod.name}
price={prod.price}
onaddtocart={handleAddToCart}>
</c-add-to-cart-button>
</template>
</div>
</lightning-card>
</template>
import { LightningElement } from 'lwc';
export default class ShoppingPage extends LightningElement {
cartCount = 0;
cartItems = [];
products = [
{ id: 'p1', name: 'Laptop Pro X', price: 1299 },
{ id: 'p2', name: 'Wireless Mouse', price: 49 },
{ id: 'p3', name: 'USB-C Hub', price: 79 }
];
handleAddToCart(event) {
// Read the payload from event.detail
const { id, name, price } = event.detail;
this.cartItems = [...this.cartItems, { id, name, price }];
this.cartCount = this.cartItems.length;
// Optionally show a toast or update UI
console.log(`Added to cart: ${name} ($${price})`);
}
}
• The child dispatches 'addtocart' with product details in event.detail.
• The parent listens via onaddtocart={handleAddToCart} — the event name prefixed with on.
• event.detail in the parent handler contains exactly what the child put in detail: { ... }.
• The parent updates cartCount and cartItems — triggering a reactive re-render.
5 Practical Example — Contact Form with Validation
A real-world scenario: a reusable contact input child validates its own field and notifies the parent when the value changes. The parent aggregates the values from multiple children and controls the Submit button.
<template>
<div class="slds-m-bottom_small">
<lightning-input
label={label}
value={value}
required={required}
onchange={handleChange}
message-when-value-missing="This field is required.">
</lightning-input>
<template lwc:if={errorMessage}>
<p style="color:#ef4444; font-size:12px; margin-top:4px;">
⚠️ {errorMessage}
</p>
</template>
</div>
</template>
import { LightningElement, api } from 'lwc';
export default class ValidatedInput extends LightningElement {
@api label = '';
@api fieldKey = ''; // identifier so parent knows which field changed
@api required = false;
value = '';
errorMessage = '';
handleChange(event) {
this.value = event.target.value;
// Simple validation
if (this.required && !this.value.trim()) {
this.errorMessage = `${this.label} is required.`;
} else {
this.errorMessage = '';
}
// Notify parent: which field changed and what the new value is
this.dispatchEvent(new CustomEvent('fieldchange', {
detail: {
key: this.fieldKey,
value: this.value,
valid: !this.errorMessage
}
}));
}
}
<template>
<lightning-card title="Contact Us" icon-name="utility:contact">
<div class="slds-p-around_medium">
<c-validated-input
label="Full Name"
field-key="name"
required
onfieldchange={handleFieldChange}>
</c-validated-input>
<c-validated-input
label="Email Address"
field-key="email"
required
onfieldchange={handleFieldChange}>
</c-validated-input>
<c-validated-input
label="Phone Number"
field-key="phone"
onfieldchange={handleFieldChange}>
</c-validated-input>
<lightning-button
label="Submit"
variant="brand"
disabled={isSubmitDisabled}
onclick={handleSubmit}
class="slds-m-top_medium">
</lightning-button>
<template lwc:if={submitted}>
<p style="color:green; margin-top:12px; font-weight:600;">
✅ Form submitted successfully!
</p>
</template>
</div>
</lightning-card>
</template>
import { LightningElement } from 'lwc';
export default class ContactForm extends LightningElement {
formData = { name: '', email: '', phone: '' };
validity = { name: false, email: false }; // required fields
submitted = false;
get isSubmitDisabled() {
// Enable Submit only when all required fields are valid
return !this.validity.name || !this.validity.email;
}
handleFieldChange(event) {
const { key, value, valid } = event.detail;
// Update the form data
this.formData = { ...this.formData, [key]: value };
// Track validity for required fields
if (key === 'name' || key === 'email') {
this.validity = { ...this.validity, [key]: valid };
}
}
handleSubmit() {
console.log('Form Data:', JSON.stringify(this.formData));
this.submitted = true;
}
}
• Each c-validated-input manages its own validation state internally — the parent only receives the result via onfieldchange.
• The parent tracks validity of the required fields to control the Submit button via a computed getter isSubmitDisabled.
• The fieldKey @api property lets the parent identify which field fired the event — a clean pattern for multi-field forms.
6 @api Methods — Calling Child Functions from Parent
Beyond properties, you can also expose methods on a child component using @api. The parent can then call these methods directly by getting a reference to the child element via this.template.querySelector().
<template>
<div style="padding:14px; background:#f8fafc; border-radius:8px;
border:1px solid #e2e8f0; text-align:center;">
<p style="font-size:28px; font-weight:700; color:#032D60;">
{displayTime}
</p>
<p style="font-size:12px; color:#94a3b8;">Timer Widget</p>
</div>
</template>
import { LightningElement, api } from 'lwc';
export default class TimerWidget extends LightningElement {
seconds = 0;
intervalId = null;
get displayTime() {
const m = Math.floor(this.seconds / 60).toString().padStart(2, '0');
const s = (this.seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
// Public methods — callable by the parent
@api
startTimer() {
if (this.intervalId) return;
this.intervalId = setInterval(() => { this.seconds++; }, 1000);
}
@api
stopTimer() {
clearInterval(this.intervalId);
this.intervalId = null;
}
@api
resetTimer() {
this.stopTimer();
this.seconds = 0;
}
}
<template>
<lightning-card title="Timer Control" icon-name="utility:clock">
<div class="slds-p-around_medium">
<!-- Child component reference -->
<c-timer-widget></c-timer-widget>
<div class="slds-m-top_medium" style="display:flex; gap:10px;">
<lightning-button label="▶ Start" onclick={startTimer} variant="brand"></lightning-button>
<lightning-button label="⏸ Stop" onclick={stopTimer} variant="neutral"></lightning-button>
<lightning-button label="↺ Reset" onclick={resetTimer} variant="destructive"></lightning-button>
</div>
</div>
</lightning-card>
</template>
import { LightningElement } from 'lwc';
export default class TimerPage extends LightningElement {
// Get a reference to the child component
get timerWidget() {
return this.template.querySelector('c-timer-widget');
}
startTimer() {
this.timerWidget.startTimer(); // calling @api method on child
}
stopTimer() {
this.timerWidget.stopTimer(); // calling @api method on child
}
resetTimer() {
this.timerWidget.resetTimer(); // calling @api method on child
}
}
• @api can decorate both properties and methods — both become part of the child's public API.
• this.template.querySelector('c-timer-widget') returns the child component element, just like querying a DOM element.
• Parent methods like startTimer() then call methods directly on the child element reference.
• Do not call child methods in connectedCallback — use renderedCallback or event handlers instead, to ensure the child is fully rendered.
@api properties + reactive rendering whenever possible. Use @api methods only for imperative actions (start, stop, reset, focus, validate).
7 CustomEvent Options — bubbles & composed
By default, a CustomEvent only travels up to the immediate parent component. You can change this behaviour with two options:
• bubbles: true — The event travels up through the component tree beyond the immediate parent.
• composed: true — The event crosses the shadow DOM boundary, allowing it to be heard by components outside the shadow root.
// Does NOT bubble — only immediate parent can hear it (recommended default)
this.dispatchEvent(new CustomEvent('selected', {
detail: { id: this.recordId }
}));
// Bubbles up through the component tree
this.dispatchEvent(new CustomEvent('selected', {
detail: { id: this.recordId },
bubbles: true
}));
// Bubbles AND crosses shadow DOM boundary (use carefully)
this.dispatchEvent(new CustomEvent('selected', {
detail: { id: this.recordId },
bubbles: true,
composed: true
}));
| Option | Default | Effect | When to use |
|---|---|---|---|
| bubbles: false | ✅ Default | Event stops at the immediate parent | Most cases — explicit, predictable |
| bubbles: true | ❌ Must opt-in | Event travels up through ancestors | Deeply nested components needing to notify a distant ancestor |
| composed: true | ❌ Must opt-in | Crosses shadow DOM boundary | Only when event must reach outside the component's shadow root |
bubbles: true, composed: true by default. It makes event flow hard to trace and can cause unintended listeners to fire. Prefer direct parent-child event handling wherever possible.
8 Parent–Child Communication — Full Summary
| Direction | Mechanism | Child code | Parent code |
|---|---|---|---|
| Parent → Child (pass data down) |
@api property |
@api myProp; |
<c-child my-prop={val}> |
| Parent → Child (call method) |
@api method |
@api doSomething() {} |
this.template.querySelector('c-child').doSomething() |
| Child → Parent (notify/send data) |
CustomEvent |
this.dispatchEvent(new CustomEvent('myevent', { detail: data })) |
<c-child onmyevent={handler}> |
9 Key Rules & Best Practices
| Rule | Detail |
|---|---|
| @api is read-only in child | A child must never modify an @api property directly. Use local properties for internal state, and dispatch events to request the parent to change values. |
| camelCase → kebab-case | @api productName in JS becomes product-name as an HTML attribute. This is automatic — no special configuration needed. |
| Custom event names: lowercase only | Event names should be all-lowercase with no spaces (e.g., 'fieldchange', 'addtocart'). Avoid camelCase in event names. |
| detail should be a plain object | Pass a plain JavaScript object in event.detail. Avoid passing class instances or components — they may not serialize correctly across shadow boundaries. |
| querySelector timing | Call this.template.querySelector() only after the component has rendered (in renderedCallback or event handlers). It returns null before render. |
| Prefer properties over methods | Use reactive @api properties as much as possible. Invoke @api methods only for truly imperative actions (start, stop, focus, clear). |
| Don't bubble by default | Keep bubbles: false (the default) unless you specifically need an ancestor far up the tree to hear the event. Unbounded bubbling makes code harder to debug. |
10 Quick Reference
Expose a property on the child (@api):
import { LightningElement, api } from 'lwc';
export default class MyChild extends LightningElement {
@api title; // parent sets this as title="Hello"
@api recordId; // parent sets as record-id={someId}
}
Parent sets @api property via HTML attribute:
<c-my-child title="Hello World" record-id={currentId}></c-my-child>
Child dispatches a custom event with data:
this.dispatchEvent(new CustomEvent('itemselected', {
detail: { id: this.itemId, name: this.itemName }
}));
Parent listens and reads event.detail:
<!-- HTML -->
<c-my-child onitemselected={handleItemSelected}></c-my-child>
// JS
handleItemSelected(event) {
const { id, name } = event.detail;
this.selectedId = id;
}
Calling a child @api method from parent:
// Parent JS
this.template.querySelector('c-my-child').resetForm();
@api properties, and signals flow up via CustomEvent. Children never modify parent state directly. Keep events specific, payloads plain, and bubbling off by default for predictable, maintainable component trees.