DEV Community

Cory Rylan
Cory Rylan

Posted on • Originally published at coryrylan.com on

Using Event Decorators with lit-element and Web Components

When Web Components need to communicate state changes to the application, it uses Custom Events, just like native events built into the browser. Let’s take a look at a simple example of a component emitting a custom event.

const template = document.createElement('template');
template.innerHTML = `<button>Emit Event!</button>`;

export class Widget extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));

    this.shadowRoot.querySelector('button').addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('myCustomEvent', { detail: 'hello there' }));
    });
  }
}

customElements.define('my-widget', Widget);
Enter fullscreen mode Exit fullscreen mode

Our widget is a basic custom element containing a single button. With our widget, we can listen to the click event from the component template to trigger a custom event.

this.dispatchEvent(new CustomEvent('myCustomEvent', { detail: 'hello there' }));
Enter fullscreen mode Exit fullscreen mode

With custom events, we pass an event name and a configuration object. This configuration object allows us to pass a value using the detail property. Once we have the event setup, we can listen to our new custom event.

<my-widget></my-widget>
Enter fullscreen mode Exit fullscreen mode
import './widget';

const widget = document.querySelector('my-widget');

widget.addEventListener('myCustomEvent', (event) => {
  alert(`myCustomEvent:, ${event.detail}`);
});
Enter fullscreen mode Exit fullscreen mode

Just like any other DOM event, we can create an event listener to get notified when our event is triggered. We can improve the reliability of our custom events by using TypeScript decorators to create a custom @event decorator.

Example Alert Web Component

For our example, we will be making a simple alert component to show messages to the user. This component will have a single property to determine if the alert can be dismissed and a single event to notify the application when the user has clicked the dismiss button.

A simple alert Web Component

Our Web Component is using lit-element. Lit Element is a lightweight library for making it easy to build Web Components. Lit Element and its templating library lit-html provide an easy way to bind data and render HTML within our components. Here is our example component:

import { LitElement, html, css, property } from 'lit-element';
import { event, EventEmitter } from './event';

class Alert extends LitElement {
  @property() dismiss = true;

  render() {
    return html`
      <slot></slot>
      ${this.dismissible
        ? html`<button aria-label="dismiss" @click=${() => this.dismissAlert()}>&times;</button>`
        : ''}
    `;
  }

  dismissAlert() {
    this.dispatchEvent(new CustomEvent('dismissChange', { detail: 'are you sure?' }));
  }
}

customElements.define('app-alert', Alert);
Enter fullscreen mode Exit fullscreen mode

Our alert component can show or hide a dismiss button. When a user clicks the dismiss button, we emit a custom event dismissChange.

this.dispatchEvent(new CustomEvent('dismissChange', { detail: 'are you sure?' }));
Enter fullscreen mode Exit fullscreen mode

By using TypeScript, we can improve handling our custom events. Custom events are dynamic, so it’s possible to make a mistake emitting different types on the same event.

this.dispatchEvent(new CustomEvent('dismissChange', { detail: 'are you sure?' }));

this.dispatchEvent(new CustomEvent('dismissChange', { detail: 100 }));
Enter fullscreen mode Exit fullscreen mode

I can emit a string or any other value and make the event type value inconsistent. This will make it hard to use the component in our application. By creating a custom decorator, we can catch some of these errors at build time.

TypeScript Decorator and Custom Events

Let’s take a look at what our custom decorator looks like in our alert Web Component.

import { LitElement, html, css, property } from 'lit-element';
import { event, EventEmitter } from './event';

class Alert extends LitElement {
  @property() dismiss = true;

  @event() dismissChange: EventEmitter<string>;

  render() {
    return html`
      <slot></slot>
      ${this.dismiss
        ? html`<button aria-label="dismiss" @click=${() => this.dismissAlert()}>&times;</button>`
        : ''}
    `;
  }

  dismissAlert() {
    this.dismissChange.emit('are you sure?');
  }
}

customElements.define('app-alert', Alert);
Enter fullscreen mode Exit fullscreen mode

Using the TypeScript decorator syntax, we can create a property in our class, which will contain an event emitter to manage our components events. The @event decorator is a custom decorator that will allow us to emit events with type safety easily.

We leverage TypeScript Generic Types to describe what type we expect to emit. In this use case, we will be emitting string values.

@event() dismissChange: EventEmitter<string>;

...

dismissAlert() {
  this.dismissChange.emit('are you sure?');

  this.dismissChange.emit(100); // error: Argument of type '100' is not assignable to parameter of type 'string'
}
Enter fullscreen mode Exit fullscreen mode

So let’s take a look at how we make a @event decorator. First, we are going to make a small EventEmitter class.

export class EventEmitter<T> {
  constructor(private target: HTMLElement, private eventName: string) {}

  emit(value: T, options?: EventOptions) {
    this.target.dispatchEvent(
      new CustomEvent<T>(this.eventName, { detail: value, ...options })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Our EventEmitter class defines a generic type <T> so we can ensure we always provide a consistent value type when emitting our event. Our decorator will create an instance of the EventEmitter and assign it to the decorated property. Because decorators are not yet standardized in JavaScript, we have to check if the decorator is being used by TypeScript or Babel. If you are not writing a library but an application, this check may not be necessary.

export function event() {
  return (protoOrDescriptor: any, name: string): any => {
    const descriptor = {
      get(this: HTMLElement) {
        return new EventEmitter(this, name !== undefined ? name : protoOrDescriptor.key);
      },
      enumerable: true,
      configurable: true,
    };

    if (name !== undefined) {
      // legacy TS decorator
      return Object.defineProperty(protoOrDescriptor, name, descriptor);
    } else {
      // TC39 Decorators proposal
      return {
        kind: 'method',
        placement: 'prototype',
        key: protoOrDescriptor.key,
        descriptor,
      };
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Decorators are just JavaScript functions that can append behavior to and existing property or class. Here we create an instance of our EventEmitter service and can start using it in our alert.

import { LitElement, html, css, property } from 'lit-element';
import { event, EventEmitter } from './event';

class Alert extends LitElement {
  @property() dismiss = true;

  @event() dismissChange: EventEmitter<string>;

  render() {
    return html`
      <slot></slot>
      ${this.dismiss
        ? html`<button aria-label="dismiss" @click=${() => this.dismissAlert()}>&times;</button>`
        : ''}
    `;
  }

  dismissAlert() {
    // type safe event decorator, try adding a non string value to see the type check
    this.dismissChange.emit('are you sure?');
  }
}

customElements.define('app-alert', Alert);
Enter fullscreen mode Exit fullscreen mode

If you want an in-depth tutorial about TypeScript property decorators, check out Introduction to TypeScript Property Decorators. The full working demo can be found here https://stackblitz.com/edit/typescript-xuydzd

Oldest comments (0)