Skip to content

Polymer Events — Handling and Dispatching Events in LitElement

DodaTech Updated 2026-06-28 5 min read

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

  1. Forgetting composed: true - Events from Shadow Dom need composed to cross boundary.

  2. Using arrow functions in @event bindings - Creates new function per render. OK for inline, use method references otherwise.

  3. Not preventing default - Use e.preventDefault() for forms, links, key events.

  4. Memory leaks from event listeners - LitElement manages @event bindings. Clean up window/document listeners.

  5. Using stopPropagation unnecessarily - Prevents parent components from receiving events.

Practice Questions

  1. How do you bind an event listener declaratively in LitElement?
  2. How do you dispatch a custom event that bubbles through shadow DOM?
  3. How does event delegation reduce the number of event listeners?
  4. What is the composed event property and when is it needed?
  5. 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

Can I use addEventListener inside LitElement?

Yes, but prefer @event syntax. Use addEventListener for window/document or imperative listeners.

How do I pass event handler parameters?

Use an arrow function: @click=${() => handler(arg)} or curry the handler.

What event phases does LitElement support?

The @event syntax binds to the bubble phase. Use @event.capture for capture phase.

How do I remove event listeners?

LitElement automatically cleans up declarative (@event) bindings on re-render.

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