Chapter 14: Event-Driven Communication
Modern applications are composed of many independent components that need to communicate with each other. A sensor reading arrives and multiple parts of your UI need to update. A user adds an item to their cart and you need to refresh the cart badge, update analytics, and maybe show a notification. A threshold alert triggers and you need to log it, display it, and possibly send a notification.
The naive approach is direct coupling: Component A calls a method on Component B, which calls a method on Component C. This creates a tangled web of dependencies where every component needs to know about every other component it affects. Testing becomes a nightmare. Reusing components in different contexts becomes impossible. Adding new features means modifying existing code in multiple places.
Event-driven architecture solves this problem by decoupling components through events. Instead of components calling each other directly, they communicate through events: Component A emits an event, and any component that cares about that event can listen for it. Components don't need to know about each other—they only need to know about the events they care about.
This chapter explores event-driven communication as a framework-agnostic pattern. We'll examine why event-driven architecture matters for MVVM, explore the publish-subscribe (pub/sub) pattern, and show how to implement it using event-bus-core from the Web Loom monorepo as a concrete example. But remember: the patterns we discuss are transferable to any event library, native browser APIs, or even your own custom implementation.
Why Event-Driven Communication Matters for MVVM
In MVVM architecture, the separation of concerns across Model, ViewModel, and View layers creates natural boundaries. But these layers—and the components within them—still need to communicate. Event-driven communication provides a clean way to enable this communication without creating tight coupling.
The Problem: Tight Coupling
Consider a greenhouse monitoring system without events:
// ❌ Tightly coupled: direct dependencies between components
class SensorDashboard {
constructor(
private alertPanel: AlertPanel,
private analyticsService: AnalyticsService,
private notificationService: NotificationService
) {}
handleSensorReading(reading: SensorReading) {
// Update own state
this.updateDisplay(reading);
// Directly call other components
if (reading.value > reading.threshold) {
this.alertPanel.showAlert(reading);
this.analyticsService.trackThresholdExceeded(reading);
this.notificationService.notify('Threshold exceeded!');
}
}
}This approach has several problems:
-
Tight coupling:
SensorDashboardmust know aboutAlertPanel,AnalyticsService, andNotificationService. It can't be reused without these dependencies. -
Testing complexity: To test
SensorDashboard, you must mock all three dependencies, even if you're only testing the display logic. -
Inflexibility: Adding a new feature (like logging to a database) requires modifying
SensorDashboardto add another dependency. -
Framework lock-in: If these components use framework-specific APIs, the entire chain is locked to that framework.
The Solution: Event-Driven Communication
With events, components communicate through a shared event bus without knowing about each other:
// ✅ Decoupled: components communicate through events
class SensorDashboard {
constructor(private eventBus: EventBus) {}
handleSensorReading(reading: SensorReading) {
// Update own state
this.updateDisplay(reading);
// Emit an event - don't care who listens
if (reading.value > reading.threshold) {
this.eventBus.emit('sensor:threshold-exceeded', reading);
}
}
}
// Other components listen independently
class AlertPanel {
constructor(private eventBus: EventBus) {
this.eventBus.on('sensor:threshold-exceeded', (reading) => {
this.showAlert(reading);
});
}
}
class AnalyticsService {
constructor(private eventBus: EventBus) {
this.eventBus.on('sensor:threshold-exceeded', (reading) => {
this.trackThresholdExceeded(reading);
});
}
}
class NotificationService {
constructor(private eventBus: EventBus) {
this.eventBus.on('sensor:threshold-exceeded', () => {
this.notify('Threshold exceeded!');
});
}
}Now:
- Loose coupling:
SensorDashboardonly depends on the event bus, not on specific components. - Easy testing: Test
SensorDashboardby verifying it emits the right events. Test listeners in isolation. - Flexibility: Add new listeners without modifying existing code.
- Framework independence: The event bus is framework-agnostic, so components can be reused across frameworks.
Event-Driven Architecture Patterns
Before diving into implementation, let's understand the core patterns that make event-driven architecture work.
The Publish-Subscribe (Pub/Sub) Pattern
The pub/sub pattern is the foundation of event-driven communication. It has three key concepts:
-
Publisher: Emits events when something interesting happens. Publishers don't know who (if anyone) is listening.
-
Subscriber: Registers interest in specific events. Subscribers don't know who published the event.
-
Event Bus: The intermediary that routes events from publishers to subscribers. It maintains the list of subscribers for each event type.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Publisher │────────>│ Event Bus │────────>│ Subscriber │
│ │ emit │ │ notify │ │
└─────────────┘ └─────────────┘ └─────────────┘
│
│ notify
▼
┌─────────────┐
│ Subscriber │
│ │
└─────────────┘
The beauty of this pattern is the decoupling: publishers and subscribers never directly reference each other. They only share knowledge of event names and payloads.
Event Naming Conventions
Good event names are crucial for maintainability. A common convention is to use namespaces with colons:
// Domain events: entity:action
'user:login'
'user:logout'
'sensor:reading-received'
'cart:item-added'
// UI events: component:action
'modal:opened'
'modal:closed'
'notification:shown'
// System events: system:state
'app:ready'
'app:error'
'network:online'
'network:offline'This convention makes it easy to:
- Group related events: All
sensor:*events are sensor-related - Filter events: Listen to all
user:*events with a wildcard (if your event bus supports it) - Avoid naming collisions:
user:updatedvsprofile:updatedare clearly different
Event Payloads
Events carry data (payloads) from publishers to subscribers. Design payloads carefully:
// ❌ Bad: Too much data, tight coupling to internal structure
eventBus.emit('sensor:reading', {
sensor: entireSensorObject,
reading: entireReadingObject,
metadata: internalMetadata,
_privateField: 'should not be exposed'
});
// ✅ Good: Minimal, focused data
eventBus.emit('sensor:reading-received', {
sensorId: 'sensor-123',
value: 25.5,
unit: 'celsius',
timestamp: Date.now()
});Guidelines for event payloads:
- Include only necessary data: Don't expose internal implementation details.
- Use primitive types when possible: Strings, numbers, booleans are easier to serialize and test.
- Be consistent: Similar events should have similar payload structures.
- Document the contract: Event names and payloads are your API—document them.
Pattern Implementation: Event-Bus-Core
The event-bus-core library (packages/event-bus-core/) from the Web Loom monorepo demonstrates a clean, framework-agnostic implementation of the pub/sub pattern. Let's examine how it works.
The EventBus Interface
The core interface is simple but powerful:
// packages/event-bus-core/src/types.ts
export type EventMap = Record<string, any[] | undefined>;
export type Listener<K extends keyof M, M extends EventMap> =
(...args: M[K] extends any[] ? M[K] : []) => void;
export interface EventBus<M extends EventMap> {
// Register a listener for one or more events
on<K extends keyof M>(event: K | K[], listener: Listener<K, M>): void;
// Register a one-time listener
once<K extends keyof M>(event: K, listener: Listener<K, M>): void;
// Unregister listeners
off<K extends keyof M>(event?: K, listener?: Listener<K, M>): void;
// Emit an event
emit<K extends keyof M>(event: K, ...args: M[K] extends any[] ? M[K] : []): void;
}This interface provides everything you need for pub/sub:
on(): Subscribe to eventsonce(): Subscribe to an event that fires only onceoff(): Unsubscribe from eventsemit(): Publish events
Type-Safe Event Definitions
One of the strengths of event-bus-core is full TypeScript support. You define your events as a type-safe map:
// Define your application's events
interface AppEvents extends EventMap {
'user:login': [{ userId: string; username: string }];
'user:logout': [];
'sensor:reading-received': [{
sensorId: string;
value: number;
unit: string;
timestamp: number
}];
'cart:item-added': [productId: string, quantity: number];
}
// Create a type-safe event bus
const eventBus = createEventBus<AppEvents>();
// TypeScript enforces correct event names and payloads
eventBus.on('user:login', (payload) => {
// payload is typed as { userId: string; username: string }
console.log(`User ${payload.username} logged in`);
});
eventBus.emit('user:login', { userId: '123', username: 'Alice' });
// TypeScript error if you pass wrong payload:
// eventBus.emit('user:login', { wrong: 'payload' }); // ❌ Error!This type safety is invaluable for large applications where events are your API between components.
The Implementation
The implementation is straightforward, built on top of event-emitter-core:
// packages/event-bus-core/src/eventBus.ts
import { EventEmitter } from '@web-loom/event-emitter-core';
import { EventBus, EventMap, Listener, GenericListener } from './types';
class EventBusImpl<M extends EventMap> implements EventBus<M> {
private emitter = new EventEmitter<M>();
on<K extends keyof M>(event: K | K[], listener: Listener<K, M>): void {
const eventNames = Array.isArray(event) ? event : [event];
eventNames.forEach((eventName) => {
this.emitter.on(eventName, listener as GenericListener);
});
}
once<K extends keyof M>(event: K, listener: Listener<K, M>): void {
this.emitter.once(event, listener as GenericListener);
}
off<K extends keyof M>(event?: K, listener?: Listener<K, M>): void {
if (!event) {
this.emitter.removeAllListeners();
return;
}
if (!listener) {
this.emitter.removeAllListeners(event);
return;
}
this.emitter.off(event, listener as GenericListener);
}
emit<K extends keyof M>(event: K, ...args: M[K] extends any[] ? M[K] : []): void {
(this.emitter.emit as any)(event, ...args);
}
}
export function createEventBus<M extends EventMap>(): EventBus<M> {
return new EventBusImpl<M>();
}Key implementation details:
-
Delegation to EventEmitter: The event bus delegates to
event-emitter-core, which handles the low-level listener management. -
Array support for
on(): You can register the same listener for multiple events:on(['event-a', 'event-b'], handler). -
Flexible
off(): Three usage patterns:off('event', handler): Remove specific listeneroff('event'): Remove all listeners for an eventoff(): Remove all listeners for all events
-
Framework-agnostic: No imports from React, Vue, Angular, or any framework. Pure TypeScript.
Real-World Example: E-Commerce Events
Let's see how event-driven communication works in a real application. The e-commerce app in the Web Loom monorepo (apps/ecommerce-mvvm/) uses events to coordinate between different parts of the application.
Defining Domain Events
First, define the events your application needs:
// apps/ecommerce-mvvm/src/infrastructure/events/app-bus.ts
import { createEventBus } from '@web-loom/event-bus-core';
interface AppEventMap extends Record<string, any[] | undefined> {
'catalog:reloaded': [count: number];
'cart:item-added': [productId: string, quantity: number];
'cart:updated': [itemCount: number, subtotalCents: number];
'checkout:completed': [orderId: string, totalCents: number];
}
export const appBus = createEventBus<AppEventMap>();These events represent important domain actions in an e-commerce system:
catalog:reloaded: The product catalog was refreshed (useful for cache invalidation)cart:item-added: A product was added to the cart (triggers analytics, UI updates)cart:updated: The cart state changed (update cart badge, recalculate totals)checkout:completed: An order was placed (trigger confirmation, clear cart, track conversion)
Publishing Events from ViewModels
ViewModels emit events when important actions occur:
// Conceptual CartViewModel
export class CartViewModel {
constructor(private eventBus: EventBus<AppEventMap>) {}
async addItem(productId: string, quantity: number): Promise<void> {
try {
// Update the cart
await this.cartService.addItem(productId, quantity);
// Emit event - don't care who listens
this.eventBus.emit('cart:item-added', productId, quantity);
// Fetch updated cart state
const cart = await this.cartService.getCart();
this.eventBus.emit('cart:updated', cart.itemCount, cart.subtotalCents);
} catch (error) {
console.error('Failed to add item to cart:', error);
throw error;
}
}
async checkout(): Promise<string> {
try {
const order = await this.cartService.checkout();
// Emit checkout completed event
this.eventBus.emit('checkout:completed', order.id, order.totalCents);
return order.id;
} catch (error) {
console.error('Checkout failed:', error);
throw error;
}
}
}The ViewModel doesn't know or care what happens when these events are emitted. It just publishes the events and moves on.
Subscribing to Events in Components
Different components subscribe to the events they care about:
// Cart badge component - shows item count
function CartBadge() {
const [itemCount, setItemCount] = useState(0);
useEffect(() => {
const unsubscribe = appBus.on('cart:updated', (count, subtotal) => {
setItemCount(count);
});
return unsubscribe; // Cleanup on unmount
}, []);
return <div className="cart-badge">{itemCount}</div>;
}
// Analytics service - tracks user behavior
class AnalyticsService {
constructor(private eventBus: EventBus<AppEventMap>) {
this.setupListeners();
}
private setupListeners(): void {
this.eventBus.on('cart:item-added', (productId, quantity) => {
this.track('add_to_cart', { productId, quantity });
});
this.eventBus.on('checkout:completed', (orderId, totalCents) => {
this.track('purchase', { orderId, revenue: totalCents / 100 });
});
}
private track(event: string, data: any): void {
// Send to analytics service
console.log('Analytics:', event, data);
}
}
// Notification service - shows toast messages
class NotificationService {
constructor(private eventBus: EventBus<AppEventMap>) {
this.setupListeners();
}
private setupListeners(): void {
this.eventBus.on('cart:item-added', () => {
this.showToast('Item added to cart', 'success');
});
this.eventBus.on('checkout:completed', () => {
this.showToast('Order placed successfully!', 'success');
});
}
private showToast(message: string, type: 'success' | 'error'): void {
// Show toast notification
console.log(`Toast [${type}]:`, message);
}
}Notice how each component:
- Subscribes independently: No component knows about the others.
- Handles cleanup: Returns an unsubscribe function to prevent memory leaks.
- Focuses on its concern: Cart badge updates UI, analytics tracks events, notifications show messages.
This is the power of event-driven architecture: you can add new listeners without modifying existing code.
Cross-Component Communication Strategies
Event-driven communication enables several powerful patterns for coordinating between components.
Pattern 1: One-to-Many Broadcasting
One publisher, many subscribers. This is the most common pattern.
// One publisher
class SensorService {
constructor(private eventBus: EventBus) {}
async fetchLatestReading(sensorId: string): Promise<void> {
const reading = await this.api.getSensorReading(sensorId);
// Broadcast to all interested parties
this.eventBus.emit('sensor:reading-received', {
sensorId,
value: reading.value,
unit: reading.unit,
timestamp: reading.timestamp
});
}
}
// Many subscribers
class SensorDashboard {
constructor(private eventBus: EventBus) {
this.eventBus.on('sensor:reading-received', (reading) => {
this.updateChart(reading);
});
}
}
class AlertMonitor {
constructor(private eventBus: EventBus) {
this.eventBus.on('sensor:reading-received', (reading) => {
this.checkThresholds(reading);
});
}
}
class DataLogger {
constructor(private eventBus: EventBus) {
this.eventBus.on('sensor:reading-received', (reading) => {
this.logToDatabase(reading);
});
}
}All three subscribers receive the same event, but each handles it differently. The publisher doesn't know or care how many subscribers exist.
Pattern 2: Event Chaining
One event triggers another event, creating a chain of reactions.
class CartViewModel {
constructor(private eventBus: EventBus) {
// Listen for item added
this.eventBus.on('cart:item-added', async (productId, quantity) => {
// Recalculate cart totals
const cart = await this.cartService.getCart();
// Emit updated cart state
this.eventBus.emit('cart:updated', cart.itemCount, cart.subtotalCents);
});
}
}
class InventoryService {
constructor(private eventBus: EventBus) {
// Listen for cart updates
this.eventBus.on('cart:updated', async (itemCount, subtotal) => {
// Check inventory availability
const available = await this.checkInventory();
// Emit inventory status
if (!available) {
this.eventBus.emit('inventory:low-stock');
}
});
}
}
class NotificationService {
constructor(private eventBus: EventBus) {
// Listen for inventory issues
this.eventBus.on('inventory:low-stock', () => {
this.showToast('Some items may be out of stock', 'warning');
});
}
}The chain: cart:item-added → cart:updated → inventory:low-stock → notification shown.
Caution: Event chains can become hard to debug if they're too long or circular. Document your event flows and avoid circular dependencies.
Pattern 3: Event Aggregation
Multiple events trigger a single aggregated event.
class DataSyncService {
private pendingChanges = new Set<string>();
constructor(private eventBus: EventBus) {
// Listen to multiple change events
this.eventBus.on([
'sensor:updated',
'greenhouse:updated',
'alert:updated'
], (entityId: string) => {
this.pendingChanges.add(entityId);
this.scheduleSync();
});
}
private scheduleSync(): void {
// Debounce and aggregate changes
clearTimeout(this.syncTimer);
this.syncTimer = setTimeout(() => {
if (this.pendingChanges.size > 0) {
// Emit aggregated event
this.eventBus.emit('data:sync-needed', Array.from(this.pendingChanges));
this.pendingChanges.clear();
}
}, 1000);
}
}This pattern is useful for batching operations or debouncing frequent events.
Pattern 4: Event Filtering
Subscribers can filter events based on payload data.
class UserNotificationService {
constructor(
private eventBus: EventBus,
private currentUserId: string
) {
// Listen to all messages, but filter for current user
this.eventBus.on('message:received', (message) => {
if (message.recipientId === this.currentUserId) {
this.showNotification(message);
}
});
}
}
// Or create a helper for filtered subscriptions
function createFilteredListener<T>(
eventBus: EventBus,
eventName: string,
filter: (payload: T) => boolean,
callback: (payload: T) => void
): () => void {
const handler = (payload: T) => {
if (filter(payload)) {
callback(payload);
}
};
eventBus.on(eventName, handler);
return () => eventBus.off(eventName, handler);
}
// Usage
const unsubscribe = createFilteredListener(
eventBus,
'sensor:reading-received',
(reading) => reading.sensorId === 'sensor-123',
(reading) => console.log('Sensor 123 reading:', reading)
);Alternative Approaches
While event-bus-core provides a clean, type-safe implementation, it's not the only way to implement event-driven communication. Let's explore alternatives.
Approach 1: Native EventTarget
Modern browsers provide the EventTarget API, which you can use for event-driven communication:
// Create a custom event target
class AppEventBus extends EventTarget {
emit(eventName: string, detail?: any): void {
this.dispatchEvent(new CustomEvent(eventName, { detail }));
}
on(eventName: string, listener: (event: CustomEvent) => void): () => void {
this.addEventListener(eventName, listener as EventListener);
return () => this.removeEventListener(eventName, listener as EventListener);
}
}
// Usage
const eventBus = new AppEventBus();
eventBus.on('user:login', (event) => {
console.log('User logged in:', event.detail);
});
eventBus.emit('user:login', { userId: '123', username: 'Alice' });Pros:
- Native browser API, no dependencies
- Works in all modern browsers
- Familiar API if you've used DOM events
Cons:
- No TypeScript type safety for event names or payloads
- Slightly more verbose (wrapping in
CustomEvent, accessingevent.detail) - No built-in support for multiple event subscriptions with one call
Approach 2: RxJS Subjects
If you're already using RxJS (common in Angular applications), you can use Subject for event communication:
import { Subject } from 'rxjs';
import { filter } from 'rxjs/operators';
// Define event types
type AppEvent =
| { type: 'user:login'; payload: { userId: string; username: string } }
| { type: 'user:logout'; payload: undefined }
| { type: 'cart:updated'; payload: { itemCount: number; subtotal: number } };
class RxEventBus {
private subject = new Subject<AppEvent>();
emit<T extends AppEvent['type']>(
type: T,
payload: Extract<AppEvent, { type: T }>['payload']
): void {
this.subject.next({ type, payload } as AppEvent);
}
on<T extends AppEvent['type']>(
type: T,
callback: (payload: Extract<AppEvent, { type: T }>['payload']) => void
): () => void {
const subscription = this.subject
.pipe(filter((event) => event.type === type))
.subscribe((event) => {
callback((event as Extract<AppEvent, { type: T }>).payload);
});
return () => subscription.unsubscribe();
}
}
// Usage
const eventBus = new RxEventBus();
eventBus.on('user:login', (payload) => {
console.log('User logged in:', payload.username);
});
eventBus.emit('user:login', { userId: '123', username: 'Alice' });Pros:
- Powerful RxJS operators for event transformation, filtering, debouncing
- Type-safe with discriminated unions
- Integrates naturally with RxJS-based architectures
Cons:
- Requires RxJS dependency (~15KB gzipped)
- More complex API if you're not familiar with RxJS
- Overkill if you only need simple pub/sub
Approach 3: Simple Custom Implementation
You can build a minimal event bus in ~30 lines of code:
type Listener = (...args: any[]) => void;
class SimpleEventBus {
private listeners = new Map<string, Set<Listener>>();
on(event: string, listener: Listener): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener);
return () => this.off(event, listener);
}
off(event: string, listener?: Listener): void {
if (!listener) {
this.listeners.delete(event);
return;
}
const listeners = this.listeners.get(event);
if (listeners) {
listeners.delete(listener);
if (listeners.size === 0) {
this.listeners.delete(event);
}
}
}
emit(event: string, ...args: any[]): void {
const listeners = this.listeners.get(event);
if (listeners) {
listeners.forEach((listener) => listener(...args));
}
}
}Pros:
- Zero dependencies
- Simple to understand and maintain
- Full control over implementation
Cons:
- No TypeScript type safety
- No advanced features (once, error handling, etc.)
- You're responsible for testing and maintaining it
Framework Integration
Event-driven communication is framework-agnostic, but each framework has its own patterns for integrating with event buses.
React Integration
In React, use useEffect to subscribe to events and clean up on unmount:
import { useEffect, useState } from 'react';
import { appBus } from './eventBus';
function CartBadge() {
const [itemCount, setItemCount] = useState(0);
useEffect(() => {
// Subscribe to cart updates
const unsubscribe = appBus.on('cart:updated', (count, subtotal) => {
setItemCount(count);
});
// Cleanup on unmount
return unsubscribe;
}, []);
return <div className="cart-badge">{itemCount}</div>;
}
// Custom hook for reusability
function useEventListener<T>(
eventName: string,
callback: (payload: T) => void
): void {
useEffect(() => {
const unsubscribe = appBus.on(eventName, callback);
return unsubscribe;
}, [eventName, callback]);
}
// Usage
function NotificationListener() {
useEventListener('notification:show', ({ message, type }) => {
showToast(message, type);
});
return null;
}Key points:
- Always return the unsubscribe function from
useEffect - Be careful with callback dependencies—use
useCallbackif the callback changes frequently - Consider creating custom hooks for common event patterns
Vue Integration
In Vue 3, use onMounted and onUnmounted lifecycle hooks:
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { appBus } from './eventBus';
const itemCount = ref(0);
onMounted(() => {
appBus.on('cart:updated', (count, subtotal) => {
itemCount.value = count;
});
});
onUnmounted(() => {
appBus.off('cart:updated');
});
</script>
<template>
<div class="cart-badge">{{ itemCount }}</div>
</template>Or create a composable for reusability:
// composables/useEventListener.ts
import { onMounted, onUnmounted } from 'vue';
import { appBus } from './eventBus';
export function useEventListener<T>(
eventName: string,
callback: (payload: T) => void
): void {
let unsubscribe: (() => void) | null = null;
onMounted(() => {
unsubscribe = appBus.on(eventName, callback);
});
onUnmounted(() => {
if (unsubscribe) {
unsubscribe();
}
});
}
// Usage in component
<script setup>
import { useEventListener } from './composables/useEventListener';
useEventListener('notification:show', ({ message, type }) => {
showToast(message, type);
});
</script>Angular Integration
In Angular, subscribe in ngOnInit and clean up in ngOnDestroy:
import { Component, OnInit, OnDestroy } from '@angular/core';
import { appBus } from './eventBus';
@Component({
selector: 'app-cart-badge',
template: `<div class="cart-badge">{{ itemCount }}</div>`
})
export class CartBadgeComponent implements OnInit, OnDestroy {
itemCount = 0;
private unsubscribe?: () => void;
ngOnInit(): void {
this.unsubscribe = appBus.on('cart:updated', (count, subtotal) => {
this.itemCount = count;
});
}
ngOnDestroy(): void {
if (this.unsubscribe) {
this.unsubscribe();
}
}
}Or create a service to manage event subscriptions:
import { Injectable, OnDestroy } from '@angular/core';
import { appBus } from './eventBus';
@Injectable()
export class EventBusService implements OnDestroy {
private subscriptions: Array<() => void> = [];
on<T>(eventName: string, callback: (payload: T) => void): void {
const unsubscribe = appBus.on(eventName, callback);
this.subscriptions.push(unsubscribe);
}
ngOnDestroy(): void {
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.subscriptions = [];
}
}Vanilla JavaScript Integration
In vanilla JavaScript, manage subscriptions manually:
class SensorDashboard {
private unsubscribers: Array<() => void> = [];
constructor(private eventBus: EventBus) {
this.setupListeners();
}
private setupListeners(): void {
const unsubscribe1 = this.eventBus.on('sensor:reading-received', (reading) => {
this.updateDisplay(reading);
});
const unsubscribe2 = this.eventBus.on('sensor:error', (error) => {
this.showError(error);
});
this.unsubscribers.push(unsubscribe1, unsubscribe2);
}
destroy(): void {
// Clean up all subscriptions
this.unsubscribers.forEach((unsubscribe) => unsubscribe());
this.unsubscribers = [];
}
private updateDisplay(reading: SensorReading): void {
document.getElementById('sensor-value')!.textContent = reading.value.toString();
}
private showError(error: Error): void {
document.getElementById('error-message')!.textContent = error.message;
}
}
// Usage
const dashboard = new SensorDashboard(eventBus);
// Later, when removing the dashboard
dashboard.destroy();Event-Driven Communication in MVVM
Let's see how event-driven communication fits into the MVVM architecture across all three layers.
Events in the Model Layer
Models can emit domain events when important state changes occur:
import { BehaviorSubject, Observable } from 'rxjs';
import { EventBus } from '@web-loom/event-bus-core';
export class SensorModel {
private dataSubject = new BehaviorSubject<Sensor | null>(null);
readonly data$: Observable<Sensor | null> = this.dataSubject.asObservable();
constructor(
private sensorId: string,
private eventBus: EventBus
) {}
async updateThreshold(newThreshold: number): Promise<void> {
const sensor = this.dataSubject.value;
if (!sensor) return;
const oldThreshold = sensor.threshold;
sensor.threshold = newThreshold;
// Update the model state
this.dataSubject.next(sensor);
// Emit domain event
this.eventBus.emit('sensor:threshold-changed', {
sensorId: this.sensorId,
oldThreshold,
newThreshold,
timestamp: Date.now()
});
// Persist to backend
await this.api.updateSensor(this.sensorId, { threshold: newThreshold });
}
processReading(reading: SensorReading): void {
const sensor = this.dataSubject.value;
if (!sensor) return;
// Check if reading exceeds threshold
if (reading.value > sensor.threshold) {
// Emit domain event
this.eventBus.emit('sensor:threshold-exceeded', {
sensorId: this.sensorId,
reading: reading.value,
threshold: sensor.threshold,
timestamp: reading.timestamp
});
}
// Emit reading received event
this.eventBus.emit('sensor:reading-received', {
sensorId: this.sensorId,
value: reading.value,
unit: reading.unit,
timestamp: reading.timestamp
});
}
}Domain events from Models represent important business events that other parts of the system might care about.
Events in the ViewModel Layer
ViewModels can both emit and listen to events:
export class GreenHouseViewModel {
private greenhouseSubject = new BehaviorSubject<GreenHouse | null>(null);
readonly greenhouse$: Observable<GreenHouse | null>;
constructor(
private greenhouseId: string,
private eventBus: EventBus
) {
this.greenhouse$ = this.greenhouseSubject.asObservable();
this.setupEventListeners();
}
private setupEventListeners(): void {
// Listen to sensor events for this greenhouse
this.eventBus.on('sensor:threshold-exceeded', (event) => {
if (this.belongsToGreenhouse(event.sensorId)) {
this.handleThresholdExceeded(event);
}
});
this.eventBus.on('sensor:reading-received', (event) => {
if (this.belongsToGreenhouse(event.sensorId)) {
this.updateSensorReading(event);
}
});
}
async addSensor(sensorData: CreateSensorDTO): Promise<void> {
try {
const sensor = await this.api.createSensor(this.greenhouseId, sensorData);
// Update local state
const greenhouse = this.greenhouseSubject.value;
if (greenhouse) {
greenhouse.sensors.push(sensor);
this.greenhouseSubject.next(greenhouse);
}
// Emit event
this.eventBus.emit('greenhouse:sensor-added', {
greenhouseId: this.greenhouseId,
sensorId: sensor.id,
timestamp: Date.now()
});
} catch (error) {
console.error('Failed to add sensor:', error);
throw error;
}
}
private handleThresholdExceeded(event: ThresholdExceededEvent): void {
// Update UI state to show alert
console.log('Threshold exceeded for sensor:', event.sensorId);
// Could emit another event for UI notifications
this.eventBus.emit('notification:show', {
message: `Sensor ${event.sensorId} exceeded threshold`,
type: 'warning'
});
}
private belongsToGreenhouse(sensorId: string): boolean {
const greenhouse = this.greenhouseSubject.value;
return greenhouse?.sensors.some(s => s.id === sensorId) ?? false;
}
dispose(): void {
// Clean up event listeners
this.eventBus.off('sensor:threshold-exceeded');
this.eventBus.off('sensor:reading-received');
}
}ViewModels act as coordinators, listening to domain events and emitting UI-related events.
Events in the View Layer
Views primarily listen to events to update the UI:
// React View
function AlertPanel() {
const [alerts, setAlerts] = useState<Alert[]>([]);
useEffect(() => {
const unsubscribe = eventBus.on('sensor:threshold-exceeded', (event) => {
setAlerts((prev) => [
...prev,
{
id: Date.now().toString(),
sensorId: event.sensorId,
message: `Sensor ${event.sensorId} exceeded threshold`,
timestamp: event.timestamp
}
]);
});
return unsubscribe;
}, []);
return (
<div className="alert-panel">
{alerts.map((alert) => (
<div key={alert.id} className="alert">
{alert.message}
</div>
))}
</div>
);
}Views should primarily listen to events, not emit them (except for user interaction events). Business logic events should come from Models or ViewModels.
Best Practices and Patterns
1. Document Your Event Contracts
Events are your API between components. Document them clearly:
/**
* Application Event Contracts
*
* These events represent the public API for cross-component communication.
* All events should be documented here with their payload structure.
*/
interface AppEvents extends EventMap {
/**
* Emitted when a user successfully logs in
* @payload userId - The unique identifier of the user
* @payload username - The user's display name
*/
'user:login': [{ userId: string; username: string }];
/**
* Emitted when a sensor reading is received
* @payload sensorId - The sensor identifier
* @payload value - The reading value
* @payload unit - The unit of measurement
* @payload timestamp - Unix timestamp of the reading
*/
'sensor:reading-received': [{
sensorId: string;
value: number;
unit: string;
timestamp: number;
}];
/**
* Emitted when a sensor reading exceeds its configured threshold
* @payload sensorId - The sensor identifier
* @payload reading - The actual reading value
* @payload threshold - The configured threshold value
* @payload timestamp - Unix timestamp when threshold was exceeded
*/
'sensor:threshold-exceeded': [{
sensorId: string;
reading: number;
threshold: number;
timestamp: number;
}];
}2. Use Namespaces Consistently
Organize events with consistent namespacing:
// Domain entity events: entity:action
'user:login'
'user:logout'
'sensor:created'
'sensor:updated'
'sensor:deleted'
// UI events: component:action
'modal:opened'
'modal:closed'
'sidebar:toggled'
// System events: system:state
'app:ready'
'app:error'
'network:online'
'network:offline'3. Avoid Event Storms
Too many events can create performance problems and make debugging difficult:
// ❌ Bad: Emitting events in a tight loop
for (let i = 0; i < 1000; i++) {
eventBus.emit('item:processed', i);
}
// ✅ Good: Batch events or debounce
const processedItems: number[] = [];
for (let i = 0; i < 1000; i++) {
processedItems.push(i);
}
eventBus.emit('items:batch-processed', processedItems);
// ✅ Good: Debounce frequent events
let debounceTimer: number;
function emitSearchQuery(query: string): void {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
eventBus.emit('search:query-changed', query);
}, 300);
}4. Clean Up Subscriptions
Always unsubscribe when components unmount to prevent memory leaks:
// ✅ Good: Store unsubscribe functions
class MyComponent {
private unsubscribers: Array<() => void> = [];
constructor(private eventBus: EventBus) {
this.unsubscribers.push(
this.eventBus.on('event1', this.handler1),
this.eventBus.on('event2', this.handler2),
this.eventBus.on('event3', this.handler3)
);
}
destroy(): void {
this.unsubscribers.forEach((unsubscribe) => unsubscribe());
this.unsubscribers = [];
}
}5. Avoid Circular Event Dependencies
Be careful not to create circular event chains:
// ❌ Bad: Circular event dependency
eventBus.on('event-a', () => {
eventBus.emit('event-b');
});
eventBus.on('event-b', () => {
eventBus.emit('event-a'); // Infinite loop!
});
// ✅ Good: Clear event flow
eventBus.on('user:action', () => {
eventBus.emit('data:changed');
});
eventBus.on('data:changed', () => {
eventBus.emit('ui:update-needed');
});
// No circular dependencies6. Use TypeScript for Type Safety
Always define event maps with TypeScript for compile-time safety:
// ✅ Good: Type-safe events
interface AppEvents extends EventMap {
'user:login': [{ userId: string; username: string }];
'cart:updated': [itemCount: number, subtotalCents: number];
}
const eventBus = createEventBus<AppEvents>();
// TypeScript catches errors
eventBus.emit('user:login', { userId: '123', username: 'Alice' }); // ✅
eventBus.emit('user:login', { wrong: 'payload' }); // ❌ Type error
eventBus.emit('typo:event', {}); // ❌ Type error7. Test Event Flows
Test that components emit and handle events correctly:
import { describe, it, expect, vi } from 'vitest';
import { createEventBus } from '@web-loom/event-bus-core';
describe('CartViewModel', () => {
it('should emit cart:updated event when item is added', async () => {
const eventBus = createEventBus();
const listener = vi.fn();
eventBus.on('cart:updated', listener);
const viewModel = new CartViewModel(eventBus);
await viewModel.addItem('product-123', 2);
expect(listener).toHaveBeenCalledWith(1, 2999); // itemCount, subtotal
});
it('should handle sensor:reading-received event', () => {
const eventBus = createEventBus();
const viewModel = new SensorViewModel(eventBus);
const updateSpy = vi.spyOn(viewModel as any, 'updateDisplay');
eventBus.emit('sensor:reading-received', {
sensorId: 'sensor-123',
value: 25.5,
unit: 'celsius',
timestamp: Date.now()
});
expect(updateSpy).toHaveBeenCalled();
});
});When to Use Event-Driven Communication
Event-driven communication is powerful, but it's not always the right choice. Here's guidance on when to use it.
Use Events When:
1. Components are loosely related
If components don't have a direct parent-child relationship and need to communicate, events are ideal:
// Cart badge and product list are siblings - use events
eventBus.emit('cart:item-added', productId, quantity);2. Multiple components need to react to the same action
When one action triggers multiple reactions across different parts of your app:
// One event, many listeners
eventBus.emit('user:login', { userId, username });
// → Update header
// → Load user preferences
// → Track analytics
// → Show welcome message3. You need to decouple dependencies
When you want to add new features without modifying existing code:
// Add new analytics tracking without modifying existing components
eventBus.on('checkout:completed', (orderId, total) => {
analytics.track('purchase', { orderId, revenue: total });
});4. Cross-cutting concerns
For concerns that span multiple features (logging, analytics, notifications):
// Centralized logging
eventBus.on(['user:login', 'user:logout', 'cart:updated'], (event) => {
logger.log(event);
});Don't Use Events When:
1. Direct parent-child communication
Use props/callbacks for direct parent-child relationships:
// ❌ Overkill: Using events for parent-child communication
function Parent() {
useEffect(() => {
eventBus.on('child:clicked', handleClick);
}, []);
return <Child />;
}
function Child() {
return <button onClick={() => eventBus.emit('child:clicked')}>Click</button>;
}
// ✅ Better: Use props
function Parent() {
return <Child onClick={handleClick} />;
}
function Child({ onClick }) {
return <button onClick={onClick}>Click</button>;
}2. Simple state sharing
For simple state sharing between components, use a shared store or context:
// ❌ Overkill: Events for simple state
eventBus.emit('theme:changed', 'dark');
// ✅ Better: Shared state
const themeStore = createStore({ theme: 'dark' });3. Request-response patterns
Events are fire-and-forget. For request-response, use promises or callbacks:
// ❌ Bad: Using events for request-response
eventBus.emit('data:fetch-requested', userId);
eventBus.on('data:fetch-completed', (data) => {
// How do we know this is for our request?
});
// ✅ Better: Use promises
const data = await fetchUserData(userId);4. Synchronous data flow
If you need synchronous, predictable data flow, use direct function calls:
// ❌ Bad: Events for synchronous validation
eventBus.emit('form:validate', formData);
// Wait... is it valid? When will we know?
// ✅ Better: Direct function call
const isValid = validateForm(formData);
if (isValid) {
submitForm(formData);
}Debugging Event-Driven Systems
Event-driven systems can be harder to debug than direct function calls. Here are strategies to make debugging easier.
1. Event Logging
Add logging to see what events are being emitted:
function createDebugEventBus<M extends EventMap>(): EventBus<M> {
const bus = createEventBus<M>();
return {
on: (event, listener) => {
console.log(`[EventBus] Registering listener for "${String(event)}"`);
return bus.on(event, listener);
},
once: (event, listener) => {
console.log(`[EventBus] Registering one-time listener for "${String(event)}"`);
return bus.once(event, listener);
},
off: (event, listener) => {
console.log(`[EventBus] Removing listener for "${String(event)}"`);
bus.off(event, listener);
},
emit: (event, ...args) => {
console.log(`[EventBus] Emitting "${String(event)}"`, args);
bus.emit(event, ...args);
}
};
}
// Use in development
const eventBus = process.env.NODE_ENV === 'development'
? createDebugEventBus<AppEvents>()
: createEventBus<AppEvents>();2. Event History
Track event history for debugging:
class EventBusWithHistory<M extends EventMap> {
private bus = createEventBus<M>();
private history: Array<{ event: string; args: any[]; timestamp: number }> = [];
private maxHistory = 100;
emit(event: keyof M, ...args: any[]): void {
this.history.push({
event: String(event),
args,
timestamp: Date.now()
});
if (this.history.length > this.maxHistory) {
this.history.shift();
}
this.bus.emit(event, ...args);
}
getHistory(): typeof this.history {
return [...this.history];
}
clearHistory(): void {
this.history = [];
}
// Delegate other methods to bus
on = this.bus.on.bind(this.bus);
once = this.bus.once.bind(this.bus);
off = this.bus.off.bind(this.bus);
}
// Usage
const eventBus = new EventBusWithHistory<AppEvents>();
// Later, inspect history
console.table(eventBus.getHistory());3. Event Flow Visualization
Create a visual representation of event flows:
class EventFlowTracker {
private flows = new Map<string, Set<string>>();
trackEmit(event: string, emitter: string): void {
if (!this.flows.has(event)) {
this.flows.set(event, new Set());
}
this.flows.get(event)!.add(`emit:${emitter}`);
}
trackListen(event: string, listener: string): void {
if (!this.flows.has(event)) {
this.flows.set(event, new Set());
}
this.flows.get(event)!.add(`listen:${listener}`);
}
visualize(): void {
console.log('Event Flow Map:');
this.flows.forEach((participants, event) => {
console.log(`\n${event}:`);
participants.forEach((participant) => {
console.log(` - ${participant}`);
});
});
}
}
// Usage
const tracker = new EventFlowTracker();
// In your components
class CartViewModel {
constructor(eventBus: EventBus) {
tracker.trackListen('cart:item-added', 'CartViewModel');
eventBus.on('cart:item-added', this.handleItemAdded);
}
addItem(productId: string, quantity: number): void {
tracker.trackEmit('cart:item-added', 'CartViewModel');
eventBus.emit('cart:item-added', productId, quantity);
}
}
// Later
tracker.visualize();
// Output:
// Event Flow Map:
//
// cart:item-added:
// - emit:CartViewModel
// - listen:CartBadge
// - listen:AnalyticsService
// - listen:NotificationService4. Browser DevTools Integration
Integrate with browser DevTools for better debugging:
// Add to window for DevTools access
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
(window as any).__EVENT_BUS__ = eventBus;
(window as any).__EVENT_HISTORY__ = eventBus.getHistory?.bind(eventBus);
}
// Now in browser console:
// __EVENT_BUS__.emit('test:event', { data: 'test' })
// __EVENT_HISTORY__()Key Takeaways
Event-driven communication is a powerful pattern for building decoupled, maintainable applications. Here's what to remember:
Core Concepts:
-
Pub/Sub Pattern: Publishers emit events, subscribers listen, and an event bus routes messages. Components don't need to know about each other.
-
Framework Independence: Event buses are framework-agnostic. The same event bus works in React, Vue, Angular, Lit, and vanilla JavaScript.
-
Type Safety: Use TypeScript to define event contracts with type-safe event names and payloads.
Implementation Approaches:
-
event-bus-core: Type-safe, lightweight (~1KB), zero dependencies. Great for most applications.
-
Native EventTarget: Built into browsers, no dependencies, but less type-safe and more verbose.
-
RxJS Subjects: Powerful operators for event transformation, but requires RxJS dependency and more complex API.
-
Custom Implementation: Full control, zero dependencies, but you're responsible for testing and maintenance.
Best Practices:
-
Document event contracts: Events are your API—document them clearly.
-
Use consistent naming: Adopt a namespace convention like
entity:action. -
Clean up subscriptions: Always unsubscribe when components unmount to prevent memory leaks.
-
Avoid event storms: Batch or debounce frequent events.
-
Test event flows: Verify that components emit and handle events correctly.
-
Use events judiciously: Not every communication needs events. Use props for parent-child, stores for shared state, and promises for request-response.
MVVM Integration:
-
Models emit domain events: When important business state changes occur.
-
ViewModels coordinate: Listen to domain events, emit UI events.
-
Views react: Listen to events to update the UI, but rarely emit business events.
Debugging:
-
Add logging: Log event emissions and subscriptions in development.
-
Track history: Keep a history of recent events for debugging.
-
Visualize flows: Create tools to visualize which components emit and listen to which events.
Event-driven communication enables loose coupling, making your application more flexible, testable, and maintainable. Combined with MVVM's separation of concerns, it creates a powerful architecture for building complex frontend applications that can evolve over time without becoming tangled messes of dependencies.
In the next chapter, we'll explore another framework-agnostic pattern: data fetching and caching strategies. We'll see how to manage async state, implement caching, and keep data fresh—all in a way that works across any framework.