DEV Community

Cover image for Event-Driven on the Frontend: Why We Miss an Event Bus
Art Stesh
Art Stesh

Posted on

Event-Driven on the Frontend: Why We Miss an Event Bus

Introduction

Frontend applications have grown from simple pages into complex systems with dozens of independent modules, shared state, real‑time updates, and rich user interactions. As the complexity increases, one question becomes critical: how do different parts of the application communicate with each other?

The classic answers in modern frameworks are well known:

  • Pass data down via props (@Input in Angular, props in React) and bubble events up (@Output, callbacks).
  • Inject shared services (singletons) that act as a bridge between unrelated components.
  • Use a global store (Redux, NgRx, Zustand) for everything.

Each of these approaches works, but each also introduces a certain kind of coupling – a hidden dependency that makes code harder to change, test, and reason about.

Over the last ten years, working on full‑stack projects with complex UI layers, I have seen the same patterns of pain repeat:

  • A seemingly simple change (e.g., “notify another component that something happened”) requires touching six files and passing new props through three intermediate components.
  • Services that started as a small bridge slowly turn into “god objects” that know about almost every feature.
  • Unit tests become a nightmare of mocking deeply nested dependency chains.

There is another pattern, well known in backend and desktop development, but strangely underused on the frontend: an event bus (Pub/Sub). It does not replace frameworks or stores. Instead, it solves a specific class of problems that the traditional tools handle poorly – loosely coupled, one‑way or request‑response communication between arbitrary parts of the system.

In this article, the first of a series, I will examine the problems with traditional communication approaches, then introduce the event bus pattern, define what a good bus should look like, and show where it really shines. The goal is not to sell you a library, but to present an architectural idea that has made my own code cleaner, more testable, and easier to evolve.


Part 1: Problems of Classical Approaches

1.1 Communication Through the Parent (Input/Output)

The most “official” way to make two components talk to each other in frameworks like Angular or React is to lift the communication up to their closest common ancestor.

  • In Angular: the child emits an event with @Output(), the parent catches it and updates its own state, then passes the new data down via @Input() to another child.
  • In React: the parent passes a callback down, the child calls it, the parent updates its state and re‑renders the other child.

On paper this is clear and predictable. In practice, as soon as the component tree grows deeper than two levels, the problems start.

Symptom 1: Prop drilling and event forwarding

Suppose you have a deeply nested button that, when clicked, must update a status panel far away in the layout. To follow the “parent‑as‑mediator” rule, you pass the callback or event emitter through every intermediate component – even those that do not care about it.

// Example in Angular (simplified)
// Grandparent template
<app-parent(someEvent) = "onEvent()" >

// Parent component template
<app-child(someEvent) = "someEvent.emit()" >
Enter fullscreen mode Exit fullscreen mode

The parent component becomes a simple relay, adding boilerplate without any business value.

Symptom 2: The parent knows too much

The parent must be aware of both the event source and the event destination. It becomes a “god object” that orchestrates what should be independent pieces. Adding a third component that needs to react to the same event means modifying the parent again, even though the parent has no logical relation to that new listener.

Symptom 3: Hard to test

To test the interaction, you need to instantiate the parent, mock its children’s outputs, and simulate the whole chain. Isolated testing of the button or the status panel becomes impossible.

This approach works for trivial cases, but in a real application you end up with a fragile pyramid of dependencies.

Component Tree

1.2 Shared Services (DI Singletons)

A popular alternative is to inject a shared service into both components. The service holds an RxJS Subject (or a simple event emitter), and components can subscribe or push values into it.

// Shared service (Angular example)
@Injectable({providedIn: 'root'})
export class MessageService {
  private subject = new Subject<string>();
  public readonly messages$ = this.subject.asObservable();

  send(msg: string) {
    this.subject.next(msg);
  }
}
Enter fullscreen mode Exit fullscreen mode

At first glance, this solves the prop‑drilling problem. The button component injects the service and calls send(). The status panel injects the same service and subscribes to messages$. No parent involved.

However, hidden coupling appears.

Drawback 1: The service becomes a nexus

Over time, many unrelated features start using the same MessageService. You add a filter, a log, a debounce – all inside the same service. Soon it has ten subjects and a complex internal state. Every component that injects it depends on the whole accumulation.

Shared Service

Drawback 2: Implicit dependencies between modules

Module A now depends on Module B only through the service. This is not explicit in the imports, so you can easily create circular dependencies at runtime. Refactoring becomes dangerous.

Drawback 3: Leaky subscriptions

A component that subscribes to messages$ must remember to unsubscribe, otherwise you have a memory leak. Many developers forget, or they rely on framework‑specific helpers (e.g., AsyncPipe in Angular) that do not cover all cases. Cleaning up a custom Subject in a long‑living service is also non‑trivial.

Drawback 4: Testing complexity

To test the button, you need to provide a real or mocked MessageService. If you mock it, you must also replicate its Subject behaviour. If you use the real service, the state persists between tests. You end up with beforeEach(() => service = new MessageService()) and resetting everything manually.

1.3 Global Store (Redux, NgRx, Zustand)

Global stores are excellent for managing application state – data that is shared, transformed, and read by many components. But they are often misused for events: one‑time actions that do not represent persistent state.

The mismatch

When a user clicks a “refresh” button, you might dispatch a refreshRequested action. This is not a piece of state; it is a command. Yet in a Redux‑style architecture you still write a reducer (which may do nothing) or a saga/epic to handle it. That is a lot of ceremony for “notify someone”.

Boilerplate and indirection

Even a simple event requires defining an action type, an action creator, possibly a side‑effect handler, and then connecting the listener to that effect. The event may never affect the store tree, but it still travels through the whole machinery.

The coupling remains

The component that fires the event must know which action to dispatch (“what is the exact string type?”). The store is still a central hub, but now with extra rules. Adding a new listener for the same event requires modifying the saga or the effect, or adding a new reducer that watches the action – again, touching files that are not directly related.

1.4 The Bottom Line – Loss of Flexibility and Testability

All three classic methods share a common flaw: they hardwire the communication channel between the sender and the receiver(s).

  • Input/Output hardwires the parent.
  • Shared services hardwire the service name and its internal subjects.
  • Global stores hardwire the action type and the side‑effect chain.

Consequences for development:

  • Adding a new listener often means changing existing code (the parent, the service, or the reducer/effect).
  • Removing a component requires carefully untangling its subscriptions from shared services or stores.
  • Testing a single component forces you to set up the whole communication chain, making unit tests slow and brittle.

In the next part we will look at an alternative that decouples sender and receiver completely – the event bus – and examine why it has been underappreciated on the frontend.

Part 2: Event Bus as an Alternative

2.1 What Is an Event Bus in an Application?

An event bus (also called a Pub/Sub channel) is a simple architectural pattern: there is a central, well‑known channel – the bus – into which any part of the system can publish a message, and from which any other part can subscribe to messages of a certain type.

  • Publisher says: “I don’t know who needs to know this, I just fire it into the bus.”
  • Subscriber says: “I don’t know who sent this, I just react when something matching my interest appears.”

The analogy is a radio station. Transmitters broadcast on a frequency without knowing who is listening. Listeners tune into that frequency without knowing who is transmitting. The only shared knowledge is the message type – a class or identifier that describes the event.

In a frontend application, the event bus can be a singleton service (or a global instance) that holds a registry of message types and their corresponding RxJS subjects or callback lists.

Simplified conceptual implementation:

class SimpleEventBus {
  private subjects = new Map<string, Subject<any>>();

  fire(message: any): void {
    const id = message.constructor.name; // or a static ID
    if (this.subjects.has(id)) {
      this.subjects.get(id).next(message);
    }
  }

  subscribe(type: any, handler: (msg: any) => void): Subscription {
    const id = type.name;
    if (!this.subjects.has(id)) {
      this.subjects.set(id, new Subject<any>());
    }
    return this.subjects.get(id).subscribe(handler);
  }
}
Enter fullscreen mode Exit fullscreen mode

Event Bus

Of course, a production‑ready bus needs much more: typed messages, automatic cleanup, support for request‑response, etc. But this is the core idea.

2.2 Why Hasn’t It Become Mainstream on the Frontend?

If the pattern is so simple and powerful, why do most frontend developers not reach for an event bus every day?

Historical baggage

In the early days of JavaScript, “event bus” often meant attaching custom properties to window or using jQuery’s trigger/on with string events. This led to hard‑to‑debug systems where any string could be an event and any handler could fire anything. The lack of type safety and discoverability gave event buses a bad reputation.

Fear of “global” state

We have been taught that globals are evil. An event bus is, in essence, a global channel. Many developers confuse “global data” with “global communication”. Data stored in a global variable is problematic because it can be mutated from anywhere. A communication channel, on the other hand, is just a pipe – it does not hold state (or holds very little transient state). The fear is often misplaced.

Framework‑centric thinking

Angular, React, and Vue each provide “the right way” to communicate. The official documentation encourages props/callbacks or stores. An event bus is rarely mentioned, so it feels like a third‑party hack rather than a first‑class architectural choice.

Lack of good typed libraries (until recently)

For years, TypeScript was not the norm. Without types, an event bus is dangerous. With modern TypeScript and libraries that enforce typed message classes, the situation has changed completely.

2.3 What Problems Does an Event Bus Solve Naturally?

An event bus is not a silver bullet, but there is a family of problems where it fits perfectly:

  • Notifications – “Item added to cart”, “User logged in”, “Theme changed”. Many unrelated components may want to react (update badge, refresh UI, log analytics). With a bus, you just fire the event once; all subscribers react independently.

  • Communication between unrelated components – Two components that have no common parent in the tree, or that belong to different lazy‑loaded modules. A shared service would work, but it creates an explicit dependency. The bus keeps them decoupled.

  • Audit and debugging – Because all messages pass through a single channel, you can easily log every event, measure performance, or add a development tool that replays events.

  • Cross‑boundary communication – In micro‑frontend architectures, different applications running on the same page can use a shared event bus to communicate without touching each other’s internals.

  • Temporary or dynamic listeners – A modal dialog that needs to know when the data it depends on changes elsewhere. The dialog can subscribe while it exists and automatically unsubscribe on close.

2.4 Boundaries of Applicability

It is equally important to know when not to use an event bus:

  • UI state that must be consistent – The current value of an input, the selected tab, the items in a list – these should be managed by a component‑local state or a dedicated store, not sent as events.

  • Strict data flow requiring predictability – If you need to enforce a unidirectional data flow with traceable mutations, a store with strict reducers is better.

  • High‑frequency events – Mouse movements, scroll events – an event bus adds unnecessary overhead. Use direct DOM or framework events.

The event bus shines for domain events and application‑level commands, not for every tiny interaction.


Part 3: Criteria for a Good Event Bus

If we decide to adopt an event bus, what should we look for in a concrete implementation? Let me share the criteria that have proven essential over many projects.

3.1 Type Safety – The Absolute Minimum

Without compile‑time checks, an event bus is a debugging nightmare. The bus should require that each message is an instance of a typed class, not a string with a payload of any.

What “good” looks like:

// Message definition
class CartItemAdded extends PostboyGenericMessage {
  constructor(public readonly itemId: string, public readonly quantity: number) {
  }
}

// Publishing – type‑checked
bus.fire(new CartItemAdded('book-42', 1));

// Subscribing – the handler receives the correct type
bus.sub(CartItemAdded).subscribe(msg => {
  console.log(msg.itemId); // TypeScript knows this exists
});
Enter fullscreen mode Exit fullscreen mode

CartItemAdded

What to avoid:

bus.fire('cart-item-added', { id: '...' }) – the handler has no idea what the payload contains.

3.2 Automatic Subscription Management

In a framework where components are created and destroyed (Angular, React with useEffect, Vue with onUnmounted), forgetting to unsubscribe leads to memory leaks and unexpected behaviour. A good bus should either:

  • Provide a Subscription object that the component can clean up, or
  • Offer scoping/namespace features that automatically unsubscribe when a scope is destroyed.

The best solutions combine both: you can group multiple subscriptions under a named namespace and then wipe the entire namespace in one call.

Example of clean lifecycle:

class MyComponent {
  private namespace = this.bus.createNamespace('MyComponent');

  ngOnInit() {
    this.namespace.sub(EventA, () => {...
    });
    this.namespace.sub(EventB, () => {...
    });
  }

  ngOnDestroy() {
    this.namespace.destroy(); // unsubscribes everything
  }
}
Enter fullscreen mode Exit fullscreen mode

Create a Namespace

3.3 Support for Both Fire‑and‑Forget and Request‑Response

Many real‑world scenarios require not only “notify everyone” but also “ask a question and get an answer from exactly one handler”.

  • Fire‑and‑forget (generic messages) – for events, notifications, logging.
  • Request‑response (callback messages) – for commands that return a result, e.g., “save this data and tell me the new ID”.

A good bus provides two distinct APIs:

// Fire-and-forget
bus.fire(new UserLoggedOutEvent());

// Request-response – returns an Observable that emits the response
bus.fireCallback(new LoadUserDataQuery(userId)).subscribe(response => {
  console.log(response.data);
});
Enter fullscreen mode Exit fullscreen mode

Fire‑and‑Forget and Request‑Response

Under the hood, the bus ensures that for callback messages exactly one handler calls message.finish(result).

3.4 Synchronous Executors for Impure‑but‑Fast Operations

Sometimes you need a synchronous operation that returns a value immediately, for example, reading from a local cache, validating a form, or checking a permission flag. This is not a good fit for observables.

A complete event bus may include a separate executor registry where you register synchronous functions or handler classes.

// Registration
bus.exec(new ConnectExecutor(GetUserPreferencesExecutor,
  () => this.userService.getPreferences()));

// Usage
const prefs = bus.exec(new GetUserPreferencesExecutor());
Enter fullscreen mode Exit fullscreen mode

This keeps the API uniform while satisfying synchronous needs.

3.5 Middleware and Cross‑Cutting Concerns

A professional event bus should allow you to intercept messages globally.

  • Log every message with timestamps.
  • Measure execution time of callback messages.
  • Block certain events based on user permissions.
  • Add correlation IDs for distributed tracing.

The bus can expose beforeExecute and afterExecute hooks (or RxJS operators applied to all messages). This is surprisingly useful for debugging and auditing in production.

3.6 No Magic – Minimal Abstractions

Finally, a good bus should be transparent. When you look at the code, you should understand exactly what happens when you call fire() – no implicit side effects hidden in proxies or decorators. The bus is an infrastructure tool, not a framework that forces you into a specific file layout or naming convention.

Avoid libraries that require code generation, special annotations, or global state that cannot be inspected.

3.7 Summary of Criteria

Criterion Why it matters
Type safety Catches errors at compile time, improves autocompletion
Subscription management Prevents memory leaks, simplifies component code
Fire‑and‑forget + request‑response Covers 99% of real communication needs
Synchronous executors Handles fast, impure operations without RxJS overhead
Middleware Enables logging, metrics, security without touching handlers
Transparency No hidden behaviour, easy to debug and extend

In the next article, we will implement a real example using a concrete library that meets all these criteria, and see how a simple event bus can eliminate the “input/output chain” entirely.

Conclusion

We started with a simple observation: the classic ways of communication on the frontend – upward prop drilling, shared services, and global stores – all introduce a form of coupling that makes code harder to change, test, and scale. Passing events through a chain of parent components turns them into unwilling intermediaries. Shared services slowly accumulate unrelated responsibilities. Global stores force us to describe one‑time actions as if they were persistent state.

Then we looked at an older, often overlooked pattern: the event bus. Not as a replacement for everything, but as a focused solution for loosely coupled, one‑way or request‑response communication. An event bus decouples sender from receiver completely. They only need to agree on the type of the message, nothing else. You can add a new subscriber without touching the publisher, remove a component without breaking the chain, and test each piece in isolation.

We also outlined what a good event bus should look like in a modern TypeScript application: type safety, automatic subscription management, support for both fire‑and‑forget and request‑response messages, synchronous executors for fast operations, middleware hooks, and no hidden magic. These criteria come from real projects – from small prototypes to large enterprise applications with dozens of modules.

Does an event bus solve every communication problem? No. It is not meant for managing complex UI state, nor for high‑frequency DOM events. But for domain events, application commands, and cross‑component notifications, it often turns a tangled web of dependencies into a clean, inspectable flow.

For the rest of this series, I will use a concrete implementation that meets all the criteria mentioned above – @artstesh/postboy. Not because it is the only option, but because it was built with these exact principles in mind, and it has proven itself in production over several years. You could equally roll your own simple bus or choose another library. The pattern is what matters; the tool is secondary.

In the next article, we will build a complete example: two components that talk to each other without a parent, without a shared service, and without a store – just events. We will see how little code is needed and how easy it becomes to add a third listener later.

If you have been struggling with brittle component trees or services that turned into god objects, I invite you to try the event bus approach on a small feature. The shift in perspective is subtle, but the effect on maintainability is often immediate.

Thank you for reading. Feedback and questions are always welcome.

Top comments (0)