All Posts
ArchitectureEngineering
Intermediate

Event-Driven Patterns in Frontend Systems

Applying event-driven architecture on the client side — event buses, sagas, and building truly reactive UIs without the overhead.

M
Mini Bhati··8 min read
0

TLDR: A typed event bus decouples features so new behaviors can be added without touching existing code. This covers the bus implementation, sagas for async orchestration, and event sourcing for undo/redo stacks. Includes the "when not to use it" section — because over-engineering with events is its own kind of mess.

Event-driven architecture is standard vocabulary for backend engineers. On the frontend, it's underused — most client-side state management still looks like synchronous function calls dressed up as reducers. That mismatch causes real problems at scale: tight coupling between features, difficult-to-test side effects, and state that becomes impossible to reason about.

The good news: the browser's native event model is already event-driven. These patterns transfer cleanly with some discipline.

The coupling problem

Here's a cart button I've seen in approximately six different codebases:

async function addToCart(product: Product) {
  cartStore.add(product);
  analyticsService.track('cart:add', { productId: product.id });
  notificationService.show(`${product.name} added`);
  recommendationService.refresh();
}

This function has four responsibilities. It's also a testing nightmare — you have to mock four services to unit test cart addition. And every time someone adds a new "thing that should happen when a user adds to cart," this function grows.

With events:

async function addToCart(product: Product) {
  cartStore.add(product);
  eventBus.emit('cart.item.added', { product, timestamp: Date.now() });
}

// Analytics listens independently
eventBus.on('cart.item.added', ({ product }) => {
  analyticsService.track('cart:add', { productId: product.id });
});

// Recommendations listen independently
eventBus.on('cart.item.added', () => {
  recommendationService.refresh();
});

Adding a new feature that reacts to cart additions means writing a new listener — zero modifications to existing code. The cart function has one job.

Building a typed event bus

The browser's native CustomEvent works for cross-component communication, but you lose type safety. A lightweight typed bus is straightforward:

type EventMap = {
  'cart.item.added': { product: Product; timestamp: number };
  'cart.item.removed': { productId: string };
  'user.authenticated': { userId: string; roles: string[] };
  'checkout.completed': { orderId: string; total: number };
};

class TypedEventBus {
  private emitter = new EventTarget();

  emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void {
    this.emitter.dispatchEvent(
      new CustomEvent(event as string, { detail: payload })
    );
  }

  on<K extends keyof EventMap>(
    event: K,
    handler: (payload: EventMap[K]) => void
  ): () => void {
    const listener = (e: Event) => handler((e as CustomEvent).detail);
    this.emitter.addEventListener(event as string, listener);
    return () => this.emitter.removeEventListener(event as string, listener);
  }
}

export const eventBus = new TypedEventBus();

The on() method returns an unsubscribe function — critical for preventing memory leaks in component lifecycle management. Call it in your ngOnDestroy or React cleanup effect.

The EventMap type means TypeScript will catch you if you emit cart.item.added with the wrong payload shape, or if you listen to an event that doesn't exist. Events are no longer stringly-typed.

Sagas: Coordinating complex async flows

When you have multi-step async operations that need to coordinate across events, the Redux-Saga model is worth borrowing even without Redux.

A saga is a long-running process that listens for events and orchestrates side effects:

// A checkout saga: listens for checkout initiation,
// coordinates payment and fulfillment, emits outcome events
async function* checkoutSaga() {
  for await (const { orderId } of eventStream('checkout.initiated')) {
    try {
      eventBus.emit('payment.processing', { orderId });
      const payment = await paymentService.charge(orderId);

      eventBus.emit('fulfillment.processing', { orderId });
      await fulfillmentService.confirm(orderId, payment.id);

      eventBus.emit('checkout.completed', { orderId, total: payment.amount });
    } catch (error) {
      eventBus.emit('checkout.failed', { orderId, reason: error.message });
    }
  }
}

UI components subscribe to outcome events — they don't care about the orchestration logic. The checkout form listens for checkout.completed and checkout.failed. The payment spinner listens for payment.processing. Neither knows anything about the other.

Event sourcing in the browser

For features like undo/redo stacks, collaborative editing, or audit trails, event sourcing brings order to what would otherwise be a tangle of mutable state:

type DocumentEvent =
  | { type: 'text.inserted'; position: number; text: string }
  | { type: 'text.deleted'; position: number; length: number }
  | { type: 'format.applied'; range: [number, number]; format: string };

class DocumentStore {
  private events: DocumentEvent[] = [];

  apply(event: DocumentEvent) {
    this.events.push(event);
  }

  get currentState(): DocumentState {
    return this.events.reduce(applyEvent, initialState);
  }

  undo(): DocumentState {
    return this.events.slice(0, -1).reduce(applyEvent, initialState);
  }
}

Undo is trivially easy: replay all events except the last. Time-travel debugging becomes possible — replay any slice of the event log to reproduce any state the document has ever been in. I've used this pattern in a collaborative annotation tool and it made debugging user-reported "the document looked wrong" issues dramatically simpler.

When not to use events

Events add indirection. For simple parent-child component communication, props and callbacks are clearer. For localized UI state like a dropdown's open/closed, component state is sufficient.

Use events when:

  • Multiple independent features need to react to the same occurrence
  • You want to add new behaviors without modifying existing code
  • You're coordinating complex async workflows across feature boundaries

Avoid events when:

  • The flow is linear and has a single consumer
  • Debugging becomes harder than the coupling problem they'd solve
  • You're tempted to put everything through an event bus "because it's cleaner"

The goal is a codebase where features are isolated enough to be developed, tested, and deployed independently. Events are one tool for that — not a religion. I've seen codebases where event buses were overused to the point where tracing a user action through the system required following seven emitted events across four files. That's not better than the coupled version.

Found this useful? Give it a like.

Newsletter

Stay in the loop

New writing on frontend engineering, system architecture & AI — delivered straight to your inbox. No spam, unsubscribe anytime.