Hello! Let's continue our journey into advanced RxJS usage in React.
In our last lesson, we built a robust, tear-free useObservable hook using useSyncExternalStore. This provided us with a solid foundation for safely connecting React components to RxJS streams. We now have a reliable way to get data out of observables and into our UI.
Today, we'll focus on how different parts of an application can communicate with each other. The learning outcome for this lesson is to refactor a simple Subject-based event bus into a scalable, type-safe solution for cross-component communication. We'll explore why a dedicated event bus is a powerful architectural pattern, build a simple one, identify its weaknesses, and then refactor it into a professional-grade, type-safe solution that you can confidently use in large-scale applications.
1. The Case for an Event Bus
In any non-trivial application, components need to communicate. A child can call a function passed down via props, and a parent can pass new props to a child. But what about communication between distant or unrelated components? For example, how does a login form in one part of the tree tell a notification component in another part of the tree to display a "Login Successful" message?
This is where an event bus comes in. It acts as a central message broker, allowing components to publish (or "trigger") events and subscribe to (or "listen for") events without having to know about each other.

This decoupled architecture is a cornerstone of scalable front-end systems. While state management libraries like Redux can be used for this, they often bring the overhead of managing global state, which isn't always necessary for simple, transient events. As the article "Life after Redux" points out, separating your event system from your state management can lead to more flexible and less coupled architectures. RxJS provides the perfect tools to build such a system.
2. A Simple Subject-Based Event Bus
At its core, an event bus needs a channel that can receive messages from multiple sources and broadcast them to multiple listeners. This is the exact definition of an RxJS Subject.
Let's start by creating a very simple event bus.
// services/event-bus.ts
import { Subject } from 'rxjs';
// Define a loose structure for our events
interface AppEvent {
type: string;
payload?: any;
}
// The bus is just a singleton Subject
export const eventBus = new Subject<AppEvent>();
// Example Usage:
// Somewhere in a component...
// eventBus.next({ type: 'user:login', payload: { name: 'Alex' } });
// Somewhere in another component...
// eventBus.subscribe(event => {
// if (event.type === 'user:login') {
// console.log('User logged in:', event.payload.name);
// }
// });
This is functional, but it has significant drawbacks that make it unsuitable for a large project:
- No Type Safety: The
typeis just astring. A typo like'user:logni'will not be caught by the compiler and will fail silently at runtime. - Unsafe Payloads: The
payloadisany. The subscriber has no guarantee what shape the data will be for a given event type, leading to potential runtime errors (Cannot read property 'name' of undefined). - Poor Discoverability: There is no easy way for a developer to know what events are available in the system or what their payloads should look like.
This is a classic scenario where a little upfront work with TypeScript can save hours of debugging.
3. Refactoring to a Type-Safe Event Bus
Our goal is to create a system where TypeScript can validate both the event type and its corresponding payload at compile time. We will achieve this by creating a wrapper around our Subject that leverages TypeScript generics.
The core idea is to define a central "map" of all possible events and their payload types. This pattern is explained brilliantly in the context of a generic EventEmitter.
TypeScript: Building a better EventEmitter
To understand the TypeScript pattern we're about to use, watch these key segments from the video 'TypeScript: Building a better EventEmitter' by Tech Talks with Simon. While the video builds a class from scratch, we will apply its core generic pattern to our RxJS Subject.
Please watch the following segments: Introducing Generics for Type Safety (05:05 - 07:31): This is the most critical part. Pay close attention to how an EventMap generic is used with keyof and indexed access types (EventMap[K]) to create a strongly-typed link between an event's name and its arguments. Using the Type-Safe EventEmitter (09:25 - 12:53): This section demonstrates the payoff: autocompletion for event names and compile-time errors for incorrect payloads. This is the developer experience we want to achieve.
Now, let's apply this powerful pattern to our RxJS event bus.
Step 1: Define the Event Map
First, we create an interface that maps string keys (our event names) to their payload types.
// types/events.ts
// Define payload structures for each event
interface ShowNotificationPayload {
message: string;
severity: 'info' | 'success' | 'warning' | 'error';
}
interface UserProfile {
id: string;
name: string;
}
// This is our central Event Map
export interface AppEventMap {
'notification:show': ShowNotificationPayload;
'user:login': UserProfile;
'user:logout': void; // Events can also have no payload
}
Step 2: Create the Type-Safe Bus Service
Next, we'll create a class that encapsulates our Subject and provides type-safe trigger and listen methods.
// services/event-bus.ts
import { Subject, Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { AppEventMap } from '../types/events';
// A helper type to create a discriminated union of all possible event objects
type AppEvent = {
[K in keyof AppEventMap]: {
type: K;
payload: AppEventMap[K];
};
}[keyof AppEventMap];
class TypedEventBus {
// The underlying Subject is now private and strongly typed
private readonly eventSubject = new Subject<AppEvent>();
/**
* Triggers an event on the bus.
* @param type The type of the event to trigger.
* @param payload The payload for the event.
*/
trigger<T extends keyof AppEventMap>(type: T, payload: AppEventMap[T]): void {
// TypeScript ensures the payload matches the type
this.eventSubject.next({ type, payload } as AppEvent);
}
/**
* Listens for a specific event type and returns an Observable of its payload.
* @param type The type of the event to listen for.
* @returns An Observable that emits the payload of the specified event.
*/
listen<T extends keyof AppEventMap>(type: T): Observable<AppEventMap[T]> {
return this.eventSubject.asObservable().pipe(
filter((event): event is Extract<AppEvent, { type: T }> => event.type === type),
map(event => event.payload)
);
}
}
// Export a singleton instance for the application to use
export const eventBus = new TypedEventBus();
Let's break down what makes this so powerful:
AppEventDiscriminated Union: This advanced TypeScript type transforms ourAppEventMapinto a union like({ type: 'notification:show', payload: ShowNotificationPayload } | { type: 'user:login', payload: UserProfile } | ...). This allows thefilteroperator's type predicate to work correctly.trigger<T>(...): The genericTis constrained to be a key ofAppEventMap. Thepayloadargument is then typed asAppEventMap[T]. If you calltrigger('user:login', { message: 'hi' }), TypeScript will throw an error because the payload doesn't match theUserProfiletype.listen<T>(...): This method also uses a genericTfor the event type. It pipes the stream throughfilterto only pass events of the correct type, and then usesmapto extract just the payload. The return type isObservable<AppEventMap[T]>, so the subscriber gets a stream of correctly typed payloads.
We have successfully refactored our simple bus into a robust, scalable, and type-safe solution.
4. Integration with React
Now, let's use our new event bus within a React application. We'll create two components: a SettingsForm that triggers a notification, and a ToastContainer that listens for notifications and displays them.
Providing the Bus via Context
To avoid relying on a hard-coded global singleton and to make our components more testable, we can provide the event bus instance through React Context. This is a scalable pattern for providing services within a React tree.
// context/EventBusContext.tsx
import React, { createContext, useContext } from 'react';
import { eventBus } from '../services/event-bus'; // our singleton instance
const EventBusContext = createContext(eventBus);
export const useEventBus = () => useContext(EventBusContext);
// You can wrap your App in a provider if you ever need to swap implementations
// For now, the default value is sufficient.
The Listening Component (ToastContainer)
The ToastContainer needs to listen for 'notification:show' events and display them. This is a perfect use case for our useObservable hook from the previous lesson!
// components/ToastContainer.tsx
import React, { useState, useEffect } from 'react';
import { useEventBus } from '../context/EventBusContext';
import { useObservable } from '../hooks/useObservable'; // From previous lesson
// Note: A real toast container would manage a list of toasts.
// This is simplified for demonstration.
export function ToastContainer() {
const bus = useEventBus();
// We need an initial value for useObservable, so we'll use a BehaviorSubject
// that our event bus populates.
const [notification$, setNotification$] = useState(() => new BehaviorSubject(null));
useEffect(() => {
const subscription = bus.listen('notification:show').subscribe(notification$);
return () => subscription.unsubscribe();
}, [bus, notification$]);
const notification = useObservable(notification$);
if (!notification) {
return null;
}
return (
<div className={`toast toast--${notification.severity}`}>
{notification.message}
</div>
);
}
Self-correction: useObservable as we built it requires a BehaviorSubject to get a synchronous snapshot. A raw listen() stream doesn't have one. The code above shows how to bridge this: the component subscribes to the event bus and pushes emissions into a local BehaviorSubject, which useObservable can then consume.
The Triggering Component (SettingsForm)
The form component can now trigger notifications without needing any reference to the ToastContainer.
// components/SettingsForm.tsx
import React from 'react';
import { useEventBus } from '../context/EventBusContext';
export function SettingsForm() {
const bus = useEventBus();
const handleSave = () => {
console.log('Saving settings...');
// Trigger a notification event
bus.trigger('notification:show', {
message: 'Settings saved successfully!',
severity: 'success',
});
};
// Try changing 'success' to 'succes' - TypeScript will catch the error!
// Try passing a payload without 'message' - TypeScript will catch it!
return (
<button onClick={handleSave}>Save Settings</button>
);
}
With this setup, our components are fully decoupled. The SettingsForm knows nothing about how or where the notification is displayed, and the ToastContainer knows nothing about what triggered it.
Conclusion
In this lesson, we elevated a simple concept—a Subject-based event bus—into a professional, type-safe architectural pattern for cross-component communication.
Key Takeaways:
- An event bus is a powerful pattern for decoupling components in a large application.
- A simple RxJS
Subjectis a good starting point, but it lacks the type safety required for scalable development. - By defining an
EventMapand using TypeScript generics, we can create a fully type-safe wrapper around aSubject. - A discriminated union of event types is a key pattern for correctly filtering the event stream in a type-safe way.
- Providing the event bus via React Context makes it easily accessible and testable.
- Our custom
useObservablehook can be used to reactively consume event streams within our components.
Next Steps
We have now explored using BehaviorSubject for state-like data and Subject for transient events. In our next lesson, "Build a reactive store for centralized application state using a BehaviorSubject," we will formalize the concept of a state store. We'll go beyond a single BehaviorSubject to build a more structured service for managing shared application state, complete with actions and selectors, laying the groundwork for more complex state management patterns.
Can't find a good explanation? Sign up and we'll make it for you
Sign up