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);
}
}
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 {...}
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;
}
}
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);
}
}
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';
}
}
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
}
});
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');
}
}
Things to Keep in Mind
- Keep it simple. Don't create custom events unless they add real value over
CustomEvent
orEvent
. - 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)
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?