Component Composition in LWC – Parent-Child Communication

Component Composition in LWC – Parent-Child Communication

Component Composition in LWC

Parent-Child Communication — @api, Custom Events & Complete Guide with Examples

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.

🏠 Parent Component
owns state, coordinates children
                 ⬇ @api properties (data flows DOWN)      ⬆ Custom Events (data flows UP)
👤 Child A
c-product-card
📋 Child B
c-order-form
🔔 Child C
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).

⚠️ One-way data flow: LWC enforces a one-way data flow. Data flows down from parent to child via @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.

📌 Rule: Any property or method you want a parent to access on a child component must be decorated with @api.
Child Component — productCard.html
<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>
Child JS — productCard.js
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

}
Parent Component — productList.html
<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.

⚠️ Naming convention: camelCase JS property names (e.g., 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.

Parent HTML — dynamicProductList.html
<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>
Parent JS — dynamicProductList.js
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.

📌 Child dispatches: this.dispatchEvent(new CustomEvent('eventname', { detail: payload }))
📌 Parent listens: <c-child oneventname={handleEvent}>
1
User interacts with something in the child component (clicks a button, selects a value, submits a form).
2
Child dispatches a CustomEvent with an optional detail payload carrying the relevant data.
3
Event bubbles up to the parent (if bubbles: true is set) or is caught directly by the immediate parent's event handler.
4
Parent handler fires, reads event.detail, and updates its own state — triggering a re-render.
Child Component — addToCartButton.html
<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>
Child JS — addToCartButton.js
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);
    }

}
Parent HTML — shoppingPage.html
<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>
Parent JS — shoppingPage.js
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.

Child — validatedInput.html
<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>
Child JS — validatedInput.js
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
            }
        }));
    }

}
Parent HTML — contactForm.html
<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>
Parent JS — contactForm.js
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().

Child — timerWidget.html
<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>
Child JS — timerWidget.js
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;
    }

}
Parent HTML — timerPage.html
<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>
Parent JS — timerPage.js
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.

⚠️ Use sparingly: Calling child methods from the parent creates tight coupling. Prefer @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.

Child — dispatching with options
// 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
⚠️ Avoid using 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();
📝 Remember: Data flows down via @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.

Popular posts from this blog

Handling Errors in LWC

Hello World using LWC

Lightning Card in LWC