Web Loom logo
Chapter 14Framework-Agnostic Patterns

Event-Driven Communication

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:

  1. Tight coupling: SensorDashboard must know about AlertPanel, AnalyticsService, and NotificationService. It can't be reused without these dependencies.

  2. Testing complexity: To test SensorDashboard, you must mock all three dependencies, even if you're only testing the display logic.

  3. Inflexibility: Adding a new feature (like logging to a database) requires modifying SensorDashboard to add another dependency.

  4. 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: SensorDashboard only depends on the event bus, not on specific components.
  • Easy testing: Test SensorDashboard by 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:

  1. Publisher: Emits events when something interesting happens. Publishers don't know who (if anyone) is listening.

  2. Subscriber: Registers interest in specific events. Subscribers don't know who published the event.

  3. 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:updated vs profile:updated are 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:

  1. Include only necessary data: Don't expose internal implementation details.
  2. Use primitive types when possible: Strings, numbers, booleans are easier to serialize and test.
  3. Be consistent: Similar events should have similar payload structures.
  4. 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 events
  • once(): Subscribe to an event that fires only once
  • off(): Unsubscribe from events
  • emit(): 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:

  1. Delegation to EventEmitter: The event bus delegates to event-emitter-core, which handles the low-level listener management.

  2. Array support for on(): You can register the same listener for multiple events: on(['event-a', 'event-b'], handler).

  3. Flexible off(): Three usage patterns:

    • off('event', handler): Remove specific listener
    • off('event'): Remove all listeners for an event
    • off(): Remove all listeners for all events
  4. 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:

  1. Subscribes independently: No component knows about the others.
  2. Handles cleanup: Returns an unsubscribe function to prevent memory leaks.
  3. 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-addedcart:updatedinventory: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, accessing event.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 useCallback if 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 dependencies

6. 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 error

7. 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 message

3. 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:NotificationService

4. 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:

  1. Pub/Sub Pattern: Publishers emit events, subscribers listen, and an event bus routes messages. Components don't need to know about each other.

  2. Framework Independence: Event buses are framework-agnostic. The same event bus works in React, Vue, Angular, Lit, and vanilla JavaScript.

  3. Type Safety: Use TypeScript to define event contracts with type-safe event names and payloads.

Implementation Approaches:

  1. event-bus-core: Type-safe, lightweight (~1KB), zero dependencies. Great for most applications.

  2. Native EventTarget: Built into browsers, no dependencies, but less type-safe and more verbose.

  3. RxJS Subjects: Powerful operators for event transformation, but requires RxJS dependency and more complex API.

  4. Custom Implementation: Full control, zero dependencies, but you're responsible for testing and maintenance.

Best Practices:

  1. Document event contracts: Events are your API—document them clearly.

  2. Use consistent naming: Adopt a namespace convention like entity:action.

  3. Clean up subscriptions: Always unsubscribe when components unmount to prevent memory leaks.

  4. Avoid event storms: Batch or debounce frequent events.

  5. Test event flows: Verify that components emit and handle events correctly.

  6. Use events judiciously: Not every communication needs events. Use props for parent-child, stores for shared state, and promises for request-response.

MVVM Integration:

  1. Models emit domain events: When important business state changes occur.

  2. ViewModels coordinate: Listen to domain events, emit UI events.

  3. Views react: Listen to events to update the UI, but rarely emit business events.

Debugging:

  1. Add logging: Log event emissions and subscriptions in development.

  2. Track history: Keep a history of recent events for debugging.

  3. 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.

Web Loom logo
Copyright © Web Loom. All rights reserved.