The DOM and Events in LWC · Lesson 18 of 25
Events in LWC: DOM Events and CustomEvent
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 / method | Meaning |
|---|---|
event.target | the element the event came from (retargeted at shadow boundaries) |
event.currentTarget | the element whose handler is running now |
event.detail | the 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}
}
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
- Parentpasses value={rating} to the child
- Childuser clicks; dispatches 'rate' with detail.rating
- Parent handleronrate={handleRate} updates its own field
- Re-renderthe new value flows back down; the stars update
- Event names are lowercase with no spaces, no hyphens and no
onprefix: the parent writesonratefor an event calledrate. - 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:
| Configuration | Who can hear it | Use it for |
|---|---|---|
bubbles: false, composed: false (default) | only the parent, on the child's tag | almost everything – the recommended choice |
bubbles: true, composed: false | elements up to the top of the parent's template | a container in the parent handling events from many children |
bubbles: true, composed: true | every ancestor, crossing all shadow boundaries | rarely; 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>
[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>
[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
A child dispatches
new CustomEvent('onSelect', …)and the parent'sonselecthandler never runs. Fix it.Show answer
Name the event
select– lowercase and without theonprefix. The parent then listens withonselect={handleSelect}.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.