Component Communication Without a Framework
Prop drilling and global variables are signs of tight coupling. Custom Events offer a native DOM solution: components send events, others listen, without a direct reference to each other, without a framework, and with full TypeScript support.
Table of Contents
- 1. The problem of component communication
- 2. CustomEvent: structure and core principle
- 3. Transporting data with detail
- 4. Understanding bubbling and capturing
- 5. Managing event listeners correctly
- 6. A type-safe event catalog
- 7. The event bus pattern for global communication
- 8. Custom Events in Alpine.js and Web Components
- 9. Custom Events vs. other communication patterns
- 10. Summary
- 11. FAQ
1. The problem of component communication
In growing web applications, the problem of component communication shows up sooner or later: a cart icon in the navigation needs to know when a product has been added. A toast system needs to react when an error occurs somewhere on the page. A filter panel needs to update the product list. The obvious solution, direct references between components, creates tight coupling: component A must know component B, and changes to B require changes in A. That scales poorly and is hard to test.
Custom Events solve this problem with a DOM-native publish/subscribe mechanism. A component dispatches an event without knowing who is listening. Other components listen for this event without knowing who triggered it. The result is loose coupling: components can be developed, tested and swapped out independently. Custom Events are not a framework feature but part of the DOM specification, available in every modern browser, with no external dependencies and no build step.
2. CustomEvent: structure and core principle
The CustomEvent API is an extension of the native Event class. It is created with an event name and an optional configuration object, and triggered on a DOM element with element.dispatchEvent(event). The minimal syntax is new CustomEvent('cart:item-added'). The resulting event behaves like any native browser event: it can be dispatched on an element, received with addEventListener, and stopped with stopPropagation().
The convention for Custom Events is a naming scheme using a colon as separator: namespace:action. This prevents conflicts with native browser events (which never use a colon) and makes an event's origin and meaning immediately recognizable in code. Examples: cart:item-added, modal:opened, form:validated, auth:signed-in. Consistent naming is especially important when several teams work on the same codebase; a documented event catalog prevents naming conflicts and keeps the architecture navigable.
// Dispatching a basic CustomEvent on a DOM element
const productCard = document.querySelector('.product-card');
productCard.dispatchEvent(
new CustomEvent('cart:item-added', {
bubbles: true, // Event bubbles up the DOM tree
cancelable: false, // Cannot be prevented with event.preventDefault()
composed: false, // Does not cross Shadow DOM boundaries
detail: {
productId: 'SKU-9876',
name: 'Mironsoft T-Shirt',
price: 29.99,
quantity: 1,
},
})
);
// Listening anywhere up the DOM tree (thanks to bubbling)
document.addEventListener('cart:item-added', (event) => {
const { productId, name, price, quantity } = event.detail;
updateCartUI({ productId, name, price, quantity });
showAddedToCartToast(name);
});
3. Transporting data with detail
The detail property is the payload mechanism of Custom Events. It accepts any serializable JavaScript value: objects, arrays, primitive values. The listener receives the data via event.detail. One important detail: in certain browser contexts (cross-origin iframes, SharedWorker), detail is structurally cloned, an algorithm that supports many objects but not functions, DOM nodes or promises. For normal same-origin communication these restrictions are irrelevant.
A common question when using Custom Events is whether to pass data via detail or via a direct reference to a state object. The recommendation is to pass immutable snapshots in detail, i.e. shallow copies of the relevant state data at the time of the event. That keeps events traceable and testable: the listener receives all relevant information from the event itself, without needing to read global state. This follows the event-sourcing principle and makes debugging with console.log(event.detail) immediately useful.
4. Understanding bubbling and capturing
Whether a Custom Event uses bubbling determines the architecture of listener registration. With bubbles: true, the event propagates upward from its origin element through all ancestors up to document. That makes it possible to register a listener in one central place, for example document.addEventListener('cart:item-added', handler), instead of on every single product element. Without bubbles: true (the default), the listener must be registered on exactly the element that dispatches the event.
An important scenario with Custom Events and bubbling is performance. When hundreds of similar elements can dispatch Custom Events, a single delegated listener on document is more efficient than one listener per element. At the same time, global bubbling carries the risk that events unintentionally reach other components. With event.stopPropagation() the propagation can be stopped in a targeted way. The capturing model, where the listener's third parameter in addEventListener is set to true, allows events to be intercepted before they reach the target element, which is useful for authorization patterns and global guard mechanisms.
// Event delegation with bubbling (one listener for all product cards)
document.addEventListener('cart:item-added', (event) => {
// event.target: the element that dispatched the event
// event.currentTarget: the element with the listener (document here)
console.log('Dispatched from:', event.target.dataset.productId);
console.log('Item:', event.detail);
updateCartCount(event.detail.quantity);
});
// Stop propagation (prevent event from reaching document)
const modal = document.querySelector('.modal');
modal.addEventListener('cart:item-added', (event) => {
handleModalCartAction(event.detail);
event.stopPropagation(); // Does not bubble further up to document listener
});
// Capturing: intercept before reaching target
document.addEventListener('cart:item-added', (event) => {
if (!isUserLoggedIn()) {
event.stopImmediatePropagation(); // Block all other listeners
showLoginPrompt();
}
}, true); // true = capturing phase
// composed: true (cross Shadow DOM boundaries, for Web Components)
shadowHost.dispatchEvent(
new CustomEvent('web-component:ready', { bubbles: true, composed: true })
);
5. Managing event listeners correctly
A common problem with Custom Events in single-page applications and dynamic pages is forgetting to remove listeners. When a component registers an event listener on mount but forgets to remove it on unmount, memory leaks and duplicate event handling on subsequent mounts are the result. removeEventListener requires a reference to the exact same function that was passed to addEventListener, anonymous arrow functions cannot be removed.
The cleanest pattern for managing Custom Event listeners in modern JavaScript is AbortController combined with AbortSignal. Since 2021, addEventListener accepts a signal object. When the signal is aborted (controller.abort()), all listeners registered with it are removed automatically, without needing to keep track of any function reference. A single AbortController can manage all listeners of a component and turns cleanup into a one-line operation.
// AbortController-based listener cleanup (modern pattern)
class CartWidget {
#controller = new AbortController();
mount() {
const { signal } = this.#controller;
// All listeners share the same signal
document.addEventListener('cart:item-added', this.#handleItemAdded, { signal });
document.addEventListener('cart:item-removed', this.#handleItemRemoved, { signal });
document.addEventListener('auth:signed-out', this.#handleSignOut, { signal });
// Signal is also passed to fetch for cancellable requests
fetch('/api/cart', { signal }).then(/* ... */);
}
unmount() {
// Single call removes ALL listeners registered with this signal
this.#controller.abort();
}
#handleItemAdded = (event) => {
this.#render(event.detail);
};
#handleItemRemoved = (event) => {
this.#removeItem(event.detail.productId);
};
#handleSignOut = () => {
this.#clearCart();
};
}
6. A type-safe event catalog
Without typing, Custom Events can quickly become a source of subtle bugs: a typo in the event name, a wrong assumption about the shape of event.detail, an event that is no longer used but is still being dispatched. TypeScript solves this problem with a central event catalog that declares all of an application's Custom Events together with their detail types. Through interface merging with WindowEventMap (or a custom element's event map), you get full type checking and autocomplete for all custom event access.
An event catalog defined as a TypeScript interface also serves as living documentation: all of the application's Custom Events are listed in one place, along with their payloads and meaning. Newly developed components can consult the catalog to check which events already exist. When an event is removed, the TypeScript compiler shows every place that listens for it, which makes refactoring safe. That's a substantial difference compared to a string-based event system without types.
// TypeScript: type-safe custom event catalog
// Define all custom events and their detail types in one place
interface AppEventMap {
'cart:item-added': { productId: string; name: string; price: number; quantity: number };
'cart:item-removed': { productId: string };
'cart:cleared': Record<string, never>;
'modal:opened': { modalId: string; trigger: string };
'modal:closed': { modalId: string; confirmed: boolean };
'auth:signed-in': { userId: string; email: string };
'auth:signed-out': Record<string, never>;
'form:submitted': { formId: string; data: Record<string, unknown> };
}
// Type-safe dispatch helper
function dispatch<K extends keyof AppEventMap>(
target: EventTarget,
event: K,
detail: AppEventMap[K],
options: { bubbles?: boolean; composed?: boolean } = { bubbles: true }
): void {
target.dispatchEvent(new CustomEvent(event, { detail, ...options }));
}
// Type-safe listen helper
function listen<K extends keyof AppEventMap>(
target: EventTarget,
event: K,
handler: (detail: AppEventMap[K], event: CustomEvent) => void,
signal?: AbortSignal
): void {
target.addEventListener(
event,
(e: Event) => handler((e as CustomEvent<AppEventMap[K]>).detail, e as CustomEvent),
{ signal }
);
}
// Usage (fully typed, no manual casting)
dispatch(document, 'cart:item-added', { productId: 'SKU-001', name: 'T-Shirt', price: 29.99, quantity: 1 });
listen(document, 'cart:item-added', ({ productId, price }) => {
console.log(productId, price); // TypeScript knows the types
});
7. The event bus pattern for global communication
For scenarios where events should not communicate through the DOM tree, for example between unrelated components on the same page, between a main thread and a service worker, or between web workers, an event bus pattern is a good fit. An event bus is a single, shared object that acts as an event target. All components dispatch on this shared target and listen there. This is conceptually simpler than using the global document object as a bus, because the event bus is explicit and controllable.
In modern JavaScript, an event bus can be built with a single EventTarget object: const bus = new EventTarget(). The EventTarget interface supports addEventListener, removeEventListener and dispatchEvent, everything needed for Custom Events. This lightweight approach avoids all the overhead of a dedicated event bus library and uses the native DOM API instead. The event bus can be exported as an ES module and imported by every component that needs global communication.
8. Custom Events in Alpine.js and Web Components
Alpine.js has built Custom Events in as its primary communication mechanism. With $dispatch('event-name', payload), an Alpine component dispatches a custom event that bubbles up through the DOM. Other Alpine components receive it with @event-name.window="handler" or @event-name="handler". That is idiosyncratic to Alpine, but it is built on the same Custom Events, Alpine is just syntactic sugar over dispatchEvent(new CustomEvent(...)).
In Web Components, Custom Events are the standards-compliant model for communicating outward. A custom element dispatches events through its public API, and the host page and other components listen for them. With composed: true, events can cross out of the Shadow DOM; without composed, the event stops at the Shadow DOM boundary. For Web Components, it is best practice to document every dispatched event in JSDoc or in a web component manifest file, so consumers of the element know which events are available without having to read the source code.
| Communication pattern | Coupling | Good for | Drawback |
|---|---|---|---|
| Custom Events (DOM) | Loose | DOM-connected components | Browser context only |
| EventTarget event bus | Loose | Global communication, workers | No debug tool support like DevTools |
| Direct reference | Tight | Parent-child communication | High refactoring effort |
| Global variable / store | Medium | Shared state (signal/store) | Reactivity must be built separately |
| URL / query params | Loose | Cross-tab, shareable state | Only for serializable state |
9. Custom Events vs. other communication patterns
Custom Events are not a cure-all. They work great for one-way notifications (broadcast), where the sender expects no result. For request-response communication, where one component asks another for data, they are awkward. Here, direct method calls, callbacks or promises are the better choice. For reactive shared state, where several components read the same data and changes are reflected automatically, signal-based approaches (such as Alpine Store, Nano Stores or Vanilla Signals) are the cleaner solution.
The most important deciding factor is: should the sending component know who receives the event? If not, Custom Events are the right fit. If yes, a direct API or a store is better. Taken together, a clear architectural pattern emerges: Custom Events for actions and notifications (something happened), stores for state (what the current value is), and direct calls for services (what I am doing right now). This trio scales well from small vanilla JS projects up to large multi-team architectures.
Mironsoft
JavaScript architecture, Hyvä themes and framework-free development
Need a decoupled component architecture for your project?
We design custom event systems, type-safe event catalogs and loosely coupled component architectures for vanilla JS, Alpine.js and Hyvä Magento themes, without framework overhead.
Architecture review
Analyze existing coupling and create a custom event migration plan
Event catalog
Type-safe custom event definitions with TypeScript for the entire codebase
Alpine.js integration
$dispatch and @event.window for Hyvä Magento components and micro frontends
10. Summary
Custom Events are the native DOM tool for loosely coupled component communication. With new CustomEvent('name', { bubbles: true, detail: {...} }) and dispatchEvent(), events can be triggered without sender and receiver needing to know about each other. The naming scheme namespace:action, a type-safe event catalog in TypeScript, and the AbortController pattern for clean listener cleanup are the three pillars of a maintainable custom event system. For global communication beyond the DOM tree, a lightweight EventTarget bus complements the DOM-native solution.
Integration with Alpine.js via $dispatch and @event.window, along with the composed: true option for Web Components, turns Custom Events into a universally applicable communication infrastructure, from simple vanilla projects to complex Hyvä Magento themes. The most important design decision remains: Custom Events for broadcast notifications, stores for reactive state, direct calls for services. Anyone who applies this pattern consistently builds frontends that can be extended without a cascade of refactoring.
Custom Events, the essentials at a glance
Dispatching
element.dispatchEvent(new CustomEvent('ns:action', { bubbles: true, detail: {...} })), no framework needed.
Listener cleanup
AbortController with the signal option: a single controller.abort() removes all registered listeners. No manual tracking of function references.
Type safety
Event catalog as a TypeScript interface merged into WindowEventMap. Type-checked access to event.detail and autocomplete for event names.
When to use Custom Events?
Broadcast notifications where sender and receiver should stay decoupled. Not for request-response or reactive shared state.
11. FAQ: JavaScript Custom Events
1What are Custom Events?
new CustomEvent(), dispatched with dispatchEvent(), received with addEventListener().2Event vs. CustomEvent?
detail property for payload data. Always use CustomEvent for custom events.3What does bubbles: true mean?
4Passing data with Custom Events?
detail property: new CustomEvent('name', { detail: { key: 'value' } }). The listener reads event.detail.key.5Removing listeners cleanly?
{ signal }. A single controller.abort() removes all of them at once.6What is an event bus?
const bus = new EventTarget(), a shared target for global communication without a DOM hierarchy.7Custom Events in Alpine.js?
$dispatch('event', payload) to dispatch. @event.window="handler" to listen. Syntactic sugar over native Custom Events.8What is composed: true?
9Type-safe Custom Events with TypeScript?
dispatch() and listen() helper functions with generic constraints.