DEV Community

Cover image for Creating Truly Custom Events for Web Components
Burton Smith
Burton Smith

Posted on

Creating Truly Custom Events for Web Components

When building web components, communication between components and their parent applications is crucial. While the built-in DOM events like click and change work well for standard interactions, custom events give you the power to create meaningful, semantic event types that are tailored for your components.

In this post, we'll explore how to extend JavaScript's Event class to create custom events that are both powerful and maintainable.

Why Extend the Event Class?

Before diving into the how, let's understand the why we would want to extend the Event class. It allows you to:

  • Create events with custom properties and methods
  • Implement type-safe event handling
  • Create reusable event classes across multiple components
  • Add meaningful default options for events

If you decide that creating custom event subclasses is not the approach you want to take, you can strongly type your events to create a good developer experience.

Basic Custom Event Extension

Let's start with a simple example. Here's how you might create a custom event for a shopping cart component:

class CartItemAddedEvent extends Event {
  // strongly type the event target
  declare target: ShoppingCart;

  // define the parameters of the event using the constructor
  constructor(item, quantity = 1) {
    // pass the event name and any options to the base Event class
    super('cart-item-added', {
      bubbles: true,
      cancelable: true,
      composed: true
    });

    // initialize public property values
    this.item = item;
    this.quantity = quantity;
    this.timestamp = new Date();
  }

  /** Get total price for added item */
  get totalPrice(): number {
    return this.item.price * this.quantity;
  }
}

// Usage in a web component
class ShoppingCart extends HTMLElement {
  addItem(item, quantity) {
    // Add item to cart logic here...

    // Dispatch custom event
    const event = new CartItemAddedEvent(item, quantity);
    this.dispatchEvent(event);
  }
}
Enter fullscreen mode Exit fullscreen mode

Documenting Events

To document these custom events, we can use the @event or @fires JSDoc tags in the component class's documentation. In the description, we can also provide a type. This information will all be added to the Custom Element's Manifest. This is very helpful when creating custom integrations and type definitions for various environments.

/**
 * ... other documentation
 *
 * @event {CartItemAddedEvent} cart-item-added - emitted when an item is added to the cart
 */
class ShoppingCart extends HTMLElement {...}
Enter fullscreen mode Exit fullscreen mode

Global Event Map Registration

To enable automatic type inference with addEventListener, register your custom events in the global event map:

declare global {
  interface GlobalEventHandlersEventMap {
    'cart-item-added': CartItemAddedEvent;
  }
}
Enter fullscreen mode Exit fullscreen mode

Advanced Custom Event with Validation

Here's a more sophisticated example of what can be done with custom events that includes validation and error handling:

type ValidationResult = {
  isValid: boolean;
  errors: string[];
};

class FormValidationEvent extends Event {
  // strongly type the event target
  declare target: CustomForm;

  constructor(
    fieldName: string, 
    value: string, 
    validationResult: ValidationResult
  ) {
    super('form-validation', {
      bubbles: true,
      cancelable: false,
      composed: true
    });

    this.fieldName = fieldName;
    this.value = value;
    this.validationResult = validationResult;
    this.isValid = validationResult.isValid;
    this.errors = validationResult.errors || [];
  }

  static createSuccess(fieldName, value): FormValidationEvent {
    return new FormValidationEvent(fieldName, value, {
      isValid: true,
      errors: []
    });
  }

  static createError(fieldName, value, errors): FormValidationEvent {
    return new FormValidationEvent(fieldName, value, {
      isValid: false,
      errors: Array.isArray(errors) ? errors : [errors]
    });
  }

  hasError(errorType: 'tooLong' | 'invalidType' | 'required'): boolean {
    return this.errors.some(error => error.type === errorType);
  }

  getErrorMessage(): string {
    return this.errors.map(error => error.message).join(', ');
  }
}

// Usage in a form component
class CustomForm extends HTMLElement {
  validateField(fieldName, value) {
    const validationResult = this.runValidation(fieldName, value);

    // using static methods to instantiate specific event variations
    const event = validationResult.isValid 
      ? FormValidationEvent.createSuccess(fieldName, value)
      : FormValidationEvent.createError(fieldName, value, validationResult.errors);

    this.dispatchEvent(event);
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating Event Hierarchies

You can extend your custom events for complex component systems and set meaningful defaults in base classes to reduce repetitive code:

// Base event class
class ComponentEvent extends Event {
  constructor(type, options = {}) {
    super(type, {
      bubbles: true,
      cancelable: true,
      composed: true,
      ...options
    });

    this.timestamp = new Date();
    this.componentId = options.componentId || null;
  }
}

// Specific event types
class ComponentLoadedEvent extends ComponentEvent {
  constructor(componentId, loadTime) {
    super('component-loaded', { componentId });
    this.loadTime = loadTime;
  }
}

class ComponentErrorEvent extends ComponentEvent {
  constructor(componentId, error) {
    super('component-error', { componentId });
    this.error = error;
    this.severity = error.severity || 'error';
  }

  get isCritical() {
    return this.severity === 'critical';
  }
}
Enter fullscreen mode Exit fullscreen mode

Pros and Cons of Subclassing Events

Like any engineering decision, there are trade-offs with choosing to go with a custom event class or using a standard Event or CustomEvent. Here are some pros and cons to subclassing Event that teams should take into consideration when making a decision.

Pros of Extending the Event Class

Type Safety and IntelliSense

When using TypeScript or modern IDEs, extended Event classes provide excellent autocomplete and type checking without having to create new types.

Reusability

Event classes can be imported and reused across multiple components, ensuring consistency and uniformity.

Custom Logic

You can add custom logic to your events rather than repeating it in your components.

Rich API

Custom methods and getters on your event classes provide a clean API for event consumers.

Debugging

Custom event classes make debugging easier with meaningful class names in stack traces.

Event Instance Checking

Because the events are a specific class, developers can validate the event type by using instanceof. This is very helpful if you are using event delegation by listening to events on parent elements.

someDiv.addEventListener('my-event', e => {
  if (e instanceof MyCustomEvent) {
    // logic for handling the event
  }
});
Enter fullscreen mode Exit fullscreen mode

Cons of Extending the Event Class

Bundle Size

Each custom event class adds to your JavaScript bundle size (though the impact is usually minimal).

Learning Curve

Developers need to understand your custom event system, which adds complexity compared to standard DOM events.

Event Property Pollution

When creating properties and methods on a custom event, those will be collocated with the standard Event properties and methods. Teams may prefer having custom data located in a standard interface like the detail property of a CustomEvent.

Potential Overengineering

It's easy to create overly complex event hierarchies that don't provide sufficient value.

Browser Compatibility

While modern browsers support class extensions well, older browsers may encounter issues (although this is rarely a concern for modern web components in evergreen browsers).

Memory Overhead

Creating many custom event instances can use more memory than simple object literals (though this is typically negligible).

Difficult to Create Abstractions

If you are creating utilities for emitting events, it can be difficult since events will need to instantiate a custom event class. For example, a common approach with web component libraries is to create an emit utility method in a component base class that can easily be used in inherited classes.

class BaseComponent extends HTMLElement {
  emit<T = unknown>(name: string, detail?: T) {
    this.dispatchEvent(new CustomEvent<T>(name, {
      detail,
      bubbles: true,
      composed: true
    }));
  }
}

type CustomClickDetails = {
  message: string;
};

class CustomButton extends BaseComponent {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    const button = document.createElement('button');
    button.textContent = 'Click Me';

    // emit a custom event when the button is clicked
    button.addEventListener('click', () => {
      this.emit<CustomClickDetails>('custom-click', { message: 'Button was clicked!' });
    });
    this.shadowRoot?.appendChild(button);

    // emit a custom event when the component has loaded
    this.emit('loaded');
  }
}
Enter fullscreen mode Exit fullscreen mode

Things to Keep in Mind

  • Keep it simple. Don't create custom events unless they add real value over CustomEvent or Event.
  • Include relevant data. Add properties that event listeners will need.
  • Don't repeat properties and methods that the user can get from the event target.
  • Provide meaningful default options for your events.
  • Document your events. Provide clear documentation about when events fire and what data they contain.

Conclusion

Extending the JavaScript Event class is a powerful technique for creating rich, semantic event systems in web components. While it adds some complexity, the benefits of type safety, reusability, and clear APIs can outweigh the costs, especially in larger applications.

Use this technique judiciously - not every interaction requires a custom event class. However, when you do need rich event data and behavior, extending Event provides a clean and maintainable solution.

If subclassing Event looks like a good fit for you, start with simple extensions and gradually build more sophisticated event systems as your component library grows.

Top comments (1)

Collapse
 
dotallio profile image
Dotallio

Great breakdown of the trade-offs, especially around type safety vs. simplicity. Do you find yourself reaching for subclassed events or sticking to CustomEvent in your bigger projects?