Polymer Events — Handling and Dispatching Events in LitElement
In this tutorial, you will learn about Polymer Events. We cover key concepts, practical examples, and best practices to help you master this topic.
Events enable user interaction and component communication. LitElement provides declarative event binding with the @ syntax and supports custom events with composed bubbling across shadow boundaries.
What You'll Learn
- Declarative event binding (@event)
- Custom events and dispatchEvent
- Composed events across shadow boundaries
- Event delegation and event phases
- Managing event listeners
Why It Matters
Events are the primary communication channel between components. Proper event handling ensures clean component APIs and avoids memory leaks.
Real-World Use
A form library where each input dispatches value-changed events, a toolbar dispatches command events, and a dashboard coordinates multiple components through custom events.
Event Architecture
flowchart TD
A[Events] --> B[User Events]
A --> C[Custom Events]
A --> D[System Events]
B --> E[@click]
B --> F[@input]
B --> G[@change]
C --> H[dispatchEvent]
C --> I[Composed]
D --> J[Lifecycle]
D --> K[Property Changes]
style A fill:#e6f3ff,stroke:#4a90d9,stroke-width:2px
Declarative Event Binding
import { LitElement, html } from 'lit';
class EventDemo extends LitElement {
static properties = { count: { type: Number } };
constructor() { super(); this.count = 0; }
_onClick(e) {
this.count++;
console.log('Clicked', e.target);
}
_onKeydown(e) {
if (e.key === 'Enter') this._onClick(e);
}
render() {
return html`
<button @click=${this._onClick} @keydown=${this._onKeydown}>
Clicked ${this.count} times
</button>
`;
}
}
customElements.define('event-demo', EventDemo);
Expected output: @click binds click events. @keydown binds keyboard events. The event object receives the native event.
Custom Events
import { LitElement, html } from 'lit';
class Dropdown extends LitElement {
static properties = { open: { type: Boolean }, options: { type: Array } };
constructor() {
super();
this.open = false;
this.options = ['Option 1', 'Option 2', 'Option 3'];
}
_toggle() { this.open = !this.open; }
_select(option) {
this.open = false;
this.dispatchEvent(new CustomEvent('select', {
detail: { value: option },
bubbles: true,
composed: true
}));
}
render() {
return html`
<button @click=${this._toggle}>${this.open ? 'Close' : 'Open'}</button>
${this.open ? html`<ul>${this.options.map(o => html`
<li @click=${() => this._select(o)}>${o}</li>
`)}</ul>` : ''}
`;
}
}
customElements.define('my-dropdown', Dropdown);
class FormPage extends LitElement {
constructor() { super(); this.selected = ''; }
render() {
return html`
<my-dropdown @select=${e => this.selected = e.detail.value}></my-dropdown>
<p>Selected: ${this.selected}</p>
`;
}
}
customElements.define('form-page', FormPage);
Expected output: The dropdown dispatches a custom select event with bubbles: true and composed: true for cross-shadow communication.
Event Delegation
import { LitElement, html } from 'lit';
class DataTable extends LitElement {
static properties = { rows: { type: Array } };
constructor() {
super();
this.rows = [
{ id: 1, name: 'Alice', role: 'Admin' },
{ id: 2, name: 'Bob', role: 'Editor' },
{ id: 3, name: 'Charlie', role: 'Viewer' }
];
}
_onTableClick(e) {
const row = e.target.closest('[data-id]');
if (!row) return;
const id = parseInt(row.dataset.id);
const action = e.target.closest('[data-action]')?.dataset.action;
if (action === 'edit') this._editRow(id);
else if (action === 'delete') this._deleteRow(id);
else this._selectRow(id);
}
_editRow(id) { console.log('Edit row', id); }
_deleteRow(id) { this.rows = this.rows.filter(r => r.id !== id); }
_selectRow(id) { console.log('Select row', id); }
render() {
return html`
<table @click=${this._onTableClick}>
<tr><th>Name</th><th>Role</th><th>Actions</th></tr>
${this.rows.map(row => html`
<tr data-id=${row.id}>
<td>${row.name}</td>
<td>${row.role}</td>
<td>
<button data-action="edit">Edit</button>
<button data-action="delete">Delete</button>
</td>
</tr>
`)}
</table>
`;
}
}
customElements.define('data-table', DataTable);
Expected output: Single click listener on the table delegates to correct row/action handler using data attributes.
Composed Events
import { LitElement, html } from 'lit';
class InnerComponent extends LitElement {
_notify() {
this.dispatchEvent(new CustomEvent('action', {
detail: { source: 'inner' },
bubbles: true,
composed: true
}));
}
render() { return html`<button @click=${this._notify}>Notify</button>`; }
}
customElements.define('inner-comp', InnerComponent);
class OuterComponent extends LitElement {
render() {
return html`<div class="wrapper"><inner-comp></inner-comp></div>`;
}
}
customElements.define('outer-comp', OuterComponent);
document.querySelector('outer-comp').addEventListener('action', e => {
console.log('Heard from', e.detail.source, 'path:', e.composedPath());
});
Expected output: Events with composed: true cross shadow boundaries. Without composed: true, external listeners do not hear the event.
Event Modifiers and Patterns
import { LitElement, html } from 'lit';
class KeyboardNav extends LitElement {
static properties = { items: { type: Array }, activeIndex: { type: Number } };
constructor() {
super();
this.items = ['Home', 'Products', 'About', 'Contact'];
this.activeIndex = 0;
}
_onKeydown(e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
e.preventDefault();
this.activeIndex = (this.activeIndex + 1) % this.items.length;
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
e.preventDefault();
this.activeIndex = (this.activeIndex - 1 + this.items.length) % this.items.length;
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this._activate(this.activeIndex);
}
}
_activate(index) {
this.dispatchEvent(new CustomEvent('navigate', {
detail: { item: this.items[index], index },
bubbles: true,
composed: true
}));
}
render() {
return html`
<nav @keydown=${this._onKeydown}>
${this.items.map((item, i) => html`
<a href="#${item.toLowerCase()}"
class="${i === this.activeIndex ? 'active' : ''}"
@click=${e => { e.preventDefault(); this._activate(i); }}
tabindex=${i === this.activeIndex ? '0' : '-1'}>
${item}
</a>
`)}
</nav>
`;
}
}
customElements.define('keyboard-nav', KeyboardNav);
Expected output: Arrow keys navigate items. Enter activates. Event listeners on individual items delegate to _activate.
Common Mistakes
Forgetting composed: true - Events from Shadow Dom need composed to cross boundary.
Using arrow functions in @event bindings - Creates new function per render. OK for inline, use method references otherwise.
Not preventing default - Use e.preventDefault() for forms, links, key events.
Memory leaks from event listeners - LitElement manages @event bindings. Clean up window/document listeners.
Using stopPropagation unnecessarily - Prevents parent components from receiving events.
Practice Questions
- How do you bind an event listener declaratively in LitElement?
- How do you dispatch a custom event that bubbles through shadow DOM?
- How does event delegation reduce the number of event listeners?
- What is the composed event property and when is it needed?
- How do you pass data with custom events?
Challenge: Build a drag-and-drop kanban board with: dragstart/dragover/drop events, custom move-card event with composed: true, column-level event delegation, keyboard reorder, and undo support.
FAQ
Mini Project
Build a file manager with: tree of folders and files, click to select, double-click to open, context menu with custom events (rename, delete, move), keyboard shortcuts, drag-and-drop with cross-boundary composed events, and event delegation on the tree.
What's Next
Events handle interaction. Learn how Polymer Styling provides component-scoped and themeable styling.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro