LearnFrenzy
Sign in Take a free test

The DOM and Events in LWC · Lesson 18 of 25

Events in LWC: DOM Events and CustomEvent

7 min read Updated Nov 8, 2026 Saurabh Samir

Data flows down from parent to child through @api properties. Information flows up through events. Because LWC uses the browser's own event system, everything you know about DOM events applies – plus a few rules that come from the shadow DOM. Get them right and your components stay loosely coupled and reusable.

In this lesson

  • The event object: target, currentTarget, preventDefault
  • Sending data up with CustomEvent
  • bubbles and composed – what each setting does
  • Event delegation for lists

The event object

Every handler receives an Event. The properties you'll use most:

Property / methodMeaning
event.targetthe element the event came from (retargeted at shadow boundaries)
event.currentTargetthe element whose handler is running now
event.detailthe data of a CustomEvent (and of most lightning-* change events)
event.preventDefault()stop the browser's default action (e.g. a form submit reloading the page)
event.stopPropagation()stop the event travelling further up

Base components fire their own events with useful details: lightning-input's change carries event.detail.value (and event.target.value), lightning-combobox the chosen value, lightning-datatable the selected rows in rowselection.

Sending data up with CustomEvent

A child announces that something happened; the parent decides what to do about it. The child doesn't know or care who's listening:

<!-- ratingStars.html -->
<template>
    <template for:each={stars} for:item="star">
        <lightning-button key={star.value} label={star.label} data-value={star.value} onclick={handleRate}></lightning-button>
    </template>
</template>
// ratingStars.js
import { LightningElement, api } from 'lwc';

export default class RatingStars extends LightningElement {
    @api value = 0;

    get stars() {
        return [1, 2, 3, 4, 5].map((n) => ({ value: n, label: n <= this.value ? '★' + n : '☆' + n }));
    }

    handleRate(event) {
        const rating = Number(event.currentTarget.dataset.value);
        // name: lowercase, no "on" prefix; data goes in detail
        this.dispatchEvent(new CustomEvent('rate', { detail: { rating } }));
    }
}
<!-- feedbackForm.html -->
<template>
    <p>How was your support experience?</p>
    <c-rating-stars value={rating} onrate={handleRate}></c-rating-stars>
    <p>{message}</p>
</template>
// feedbackForm.js
import { LightningElement } from 'lwc';

export default class FeedbackForm extends LightningElement {
    rating = 0;
    message = 'Not rated yet';

    handleRate(event) {
        this.rating = event.detail.rating;           // the parent owns the value…
        this.message = this.rating >= 4 ? 'Thank you!' : 'Sorry – we will follow up.';
    }                                                // …and passes it back down via value={rating}
}

Result

How was your support experience?
[☆1] [☆2] [☆3] [☆4] [☆5]
Not rated yet

› click “☆4”
How was your support experience?
[★1] [★2] [★3] [★4] [☆5]
Thank you!

› click “★2”
How was your support experience?
[★1] [★2] [☆3] [☆4] [☆5]
Sorry – we will follow up.

Data down, events up

  1. Parentpasses value={rating} to the child
  2. Childuser clicks; dispatches 'rate' with detail.rating
  3. Parent handleronrate={handleRate} updates its own field
  4. Re-renderthe new value flows back down; the stars update
  • Event names are lowercase with no spaces, no hyphens and no on prefix: the parent writes onrate for an event called rate.
  • Put primitives or new objects in detail. Passing your own internal object lets the parent mutate the child's state – send a copy.

bubbles and composed

By default a CustomEvent doesn't bubble at all – only listeners on the child's host element (like onrate above) hear it. Two options change that:

ConfigurationWho can hear itUse it for
bubbles: false, composed: false (default)only the parent, on the child's tagalmost everything – the recommended choice
bubbles: true, composed: falseelements up to the top of the parent's templatea container in the parent handling events from many children
bubbles: true, composed: trueevery ancestor, crossing all shadow boundariesrarely; it exposes your event to the whole page – prefix the name to avoid clashes

Here's bubbling inside one template – a single handler on a wrapper hears every child's event:

// taskItem.js
import { LightningElement, api } from 'lwc';

export default class TaskItem extends LightningElement {
    @api taskId;
    @api label;

    handleDone() {
        this.dispatchEvent(new CustomEvent('taskdone', { bubbles: true, detail: { taskId: this.taskId } }));
    }
}
<!-- taskItem.html -->
<template>
    <lightning-button label={label} onclick={handleDone}></lightning-button>
</template>
// taskBoard.js
import { LightningElement } from 'lwc';

export default class TaskBoard extends LightningElement {
    tasks = [
        { id: 't1', label: 'Call Acme' },
        { id: 't2', label: 'Send quote' },
        { id: 't3', label: 'Book demo' },
    ];

    handleTaskDone(event) {
        // one handler on the wrapper; target is the c-task-item that fired the event
        const { taskId } = event.detail;
        this.tasks = this.tasks.filter((t) => t.id !== taskId);
    }

    get remaining() {
        const n = this.tasks.length;
        return `${n} ${n === 1 ? 'task' : 'tasks'} left`;
    }
}
<!-- taskBoard.html -->
<template>
    <div ontaskdone={handleTaskDone}>
        <template for:each={tasks} for:item="task">
            <c-task-item key={task.id} task-id={task.id} label={task.label}></c-task-item>
        </template>
    </div>
    <p>{remaining}</p>
</template>

Result

[Call Acme] [Send quote] [Book demo]
3 tasks left

› click “Send quote”
[Call Acme] [Book demo]
2 tasks left

› click “Call Acme”
[Book demo]
1 task left

Siblings and distant components

Events only travel up the tree. For components that aren't parent and child – two components side by side on a Lightning page – use Lightning Message Service (lightning/messageService) instead of composed events.

Event delegation in your own template

The same idea works with ordinary DOM events: native events like click bubble, so one handler on a container can serve a whole list. Read data-* from the element that was actually clicked:

// filterChips.js
import { LightningElement } from 'lwc';

export default class FilterChips extends LightningElement {
    options = ['Hot', 'Warm', 'Cold'];
    selected = new Set();
    summary = 'No filters';

    get chips() {
        return this.options.map((o) => ({ name: o, label: this.selected.has(o) ? `✓ ${o}` : o }));
    }

    handleClick(event) {
        const name = event.target.dataset.name;      // the clicked chip, not the container
        if (!name) return;                           // a click on the gap between chips
        const next = new Set(this.selected);
        if (next.has(name)) next.delete(name);
        else next.add(name);
        this.selected = next;                        // new Set → re-render
        this.summary = next.size ? `Showing: ${[...next].join(', ')}` : 'No filters';
    }
}
<!-- filterChips.html -->
<template>
    <div onclick={handleClick}>
        <template for:each={chips} for:item="chip">
            <lightning-button key={chip.name} label={chip.label} data-name={chip.name}></lightning-button>
        </template>
    </div>
    <p>{summary}</p>
</template>

Result

[Hot] [Warm] [Cold]
No filters

› click “Hot”
[✓ Hot] [Warm] [Cold]
Showing: Hot

› click “Cold”
[✓ Hot] [Warm] [✓ Cold]
Showing: Hot, Cold

› click “✓ Hot”
[Hot] [Warm] [✓ Cold]
Showing: Cold

With delegation, here event.target (what was clicked) is the right property, whereas in a handler attached to each button you'd use currentTarget.

Practice

Try it yourself

  1. A child dispatches new CustomEvent('onSelect', …) and the parent's onselect handler never runs. Fix it.

    Show answer

    Name the event select – lowercase and without the on prefix. The parent then listens with onselect={handleSelect}.

  2. A modal inside a component should close when the user presses Escape, but the Escape keydown also closes the record page's own dialog. What do you add?

    Show answer

    Call event.stopPropagation() in your keydown handler after handling Escape, so the event doesn't continue to ancestors.

Quick check

Does a CustomEvent bubble by default?

No – bubbles and composed are both false by default.

Where does event data go?

In the detail property.

How do unrelated components on a page communicate?

With Lightning Message Service.

Summary

  • Data goes down through @api; events go up with CustomEvent and detail.
  • Name events in lowercase without "on"; listen with on + name.
  • Keep bubbles and composed false unless you have a reason; avoid composed events across the page.
  • Use delegation with event.target and data-* for lists.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Search LearnFrenzy

Esc

Quick links