EventBus in React Applications
Almost every system I've worked on needed UI elements like toasts, modals, and popovers. Over the last few years, I've utilized a straightforward patten to standardize the implementation of these components.
Let's start with the system for “toasts”
Back in September 2020, I wrote about how I built a toast system using React.Context.
I encapsulated the logic within a ToastProvider
, and the toast component itself lived in ToastProvider.Content
.
import { ToastProvider } from '~/utils/toast'
// the "Provider" pyramid
<ApolloProvider>
<ToastProvider>
<ModalProvider>
<Layout>
{children}
</Layout>
// Reads the state and displays the toast
<ToastProvider.Content />
<ModalProvider.Content />
</ModalProvider>
</ToastProvider>
</ApolloProvider>
I had a hook to open the toast.
import { useToast } from '~/utils/toast'
export default function ShowToast() {
const open = useToast();
const onClick = () => open({
title: 'This is the title for this prompt',
content: <strong>Content</strong>,
});
return <button onClick={onClick}>open</button>;
}
For more details check that article.
This worked well but had a few drawbacks:
The “Provider” pyramid
When the provider value changes, the entire tree is re-rendered
Accessing functions to display a toast requires a hook. Therefore, it needs to be retrieved from a React component and passed around
For instance, to display a toast from a Socket, React wrappers were necessary
I needed two components “Provider” and “Content”, so in order to understand the system you needed to read them both
I resolved these issues by using event bus pattern (publish/subscribe). With it I, eliminated the need for React.Context.
Enter evenBus
import { emit, useEventBus } from '~/utils/eventBus';
It is a simple module with 2 public methods
emit(eventName, payload)
triggers an event from anywhere in my system.
useEventBus(eventName, (payload) => handle(payload))
is a React hook that listens for the event and executes a function with the event payload upon receipt. The hook also cleans up event handlers when components are unmounted.
That is it 😎
These simple primitives are utilized whenever I need to build systems for modals, toasts, notices, popovers, and similar components. In react-native I used this to handle signIn
/ signOut
or languageChange
flows.
Let's see how the Toast implementation changes when we have evenBus
.
import { emit, useEventBus } from '~/utils/eventBus';
// I wrap `emit` with the functions exposed from the module
// Event bus is just an implementation detail now
// Those are more used that "Content", so they are first in the file
export function openToast(toast) {
emit('toastClose', toast);
}
export function closeToast() {
emit('toastClose');
}
// Only Toast Content is needed
export function ToastContent() {
// Internal state of toast
// For more complex systems, toast can be stacked
// We can implemented stacking without changing the external interface
const [toast, setToast] = useState<IToast | null>(null);
// When toast is open, update state
useEventBus('toastOpen', (toast) => {
setToast(toast);
});
// When toast is closed, clear state
useEventBus('toastClose', () => {
setToast(null);
});
// Handle when there is no toast
if (!toast) {
return null;
}
return (
<!-- render toast -->
);
}
This ToastContent
should be placed somewhere within the React tree. No restrictions.
Now, we can use openToast
without a hook.
import { openToast } from '~/utils/toast'
export default function ShowToast() {
const onClick = () => openToast({
title: 'This is the title for this prompt',
content: <strong>Content</strong>,
});
return <button onClick={onClick}>open</button>;
}
This approach resolves all the issues I encountered with the React.Context implementation.
No need for Providers.
Reduced number of re-renders.
Fewer hooks required.
Ability to open toasts from anywhere.
Integrating calls from a Socket, for example, becomes straightforward.
Whole toast system is one simple file
Implementing modal system with evenBus
Exactly the same. 😎
A robust modal system needs to address:
Modal stacking.
Modals with URLs.
Data loading for modals.
Returning results from modals.
Lazy loading of modal JavaScript code.
Scrolling.
Keyboard support.
This is big enough for its own post, ... so I wrote one 👉 “building a modal system“ 🙈
Implementation of eventBus itself
Here is the implementation of the eventBus. I've been copying and pasting from project to project since around 2021.
// Utilizing this small, dependency-free, ~100-line library
import mitt from 'mitt';
// Mapping events to their payloads
// TypeScript ensures correct event and payload usage
// Also serves as documentation for supported events
interface IEventsMap {
// Event 'modalOpen' expects 'content' and optional 'url'
// I name events with [module][action] format
modalOpen: {
content: React.ReactNode;
url?: string;
};
// Passing `null` means no payload
modalClose: null;
toastOpen: {
title: string;
content?: IToastContent;
icon?: IToastIcon;
}
toastClose: null
// Domain events can also be defined here
commentCreated: {
id: string;
// ...
};
// ...
}
// Emitting a global event to the bus
export function emit<T extends keyof IEventsMap>(
event: T,
...payload: IEventArguments<T>
) {
if (typeof window !== 'undefined') {
eventBus.emit(event, ...payload);
} else {
console.warn('emit called in server mode');
}
}
// Hook for subscribing and unsubscribing to events
export function useEventBus<T extends keyof IEventsMap>(
eventName: T,
handler: (...payload: IEventArguments<T>) => void,
) {
React.useEffect(() => {
eventBus.on(eventName, handler);
return () => eventBus.off(eventName, handler);
}, [eventName, handler]);
}
// TypeScript helper type for matching an event with its payload type
// The most ✨ magical part of this file
type IEventArguments<T extends keyof IEventsMap> =
null extends IEventsMap[T] ? [] : [IEventsMap[T]];
// Creating one global instance of mitt
// This is an implementation detail
const eventBus = mitt() as {
emit<T extends keyof IEventsMap>(
e: T, ...p: IEventArguments<T>,
): void;
on<T extends keyof IEventsMap>(
e: T,
f: (...p: IEventArguments<T>) => void,
): void;
off<T extends keyof IEventsMap>(
e: T,
f: (...p: IEventArguments<T>) => void,
): void;
};
Here is a gist with the code above 👉 link.
Event Types
In each project, the IEventsMap changes to accommodate the necessary events. I've considered moving this to a separate file when the list grows. However, so far, I have had 10-15 events at most.
In my projects, I have noticed, that there are two types of events:
UI events -
toastOpen
,modalOpen
,popoverOpen.
Domain events -
userSignIn
,userSignOut
,languageChanged
,commentCreated
.
Most of my events are UI-related because they can be triggered anywhere and have a clear purpose.
I seldom use domain events in projects. For domain-related activities, there are often better patterns than an event bus.
Typically, actions related to domain objects are contained within a single screen, where passing callbacks is clearer and more straightforward.
Q&A
Should every interaction be moved to the event bus?
No, not. 🙅
The events are useful in specific scenarios and not others. It excels when you need to trigger an action from anywhere in the system without expecting a response.
Domain events, in particular, should be used rarely. There are often better patterns that can be employed.
Why not use redux / mobx / RxJS.... whatever
Many of those solutions rely on React.Context and share the same limitations as my original toast provider.
State management varies from project to project. Most often, I don't use a state library, just Apollo.
The events in eventBus often don't change state; they report that the state was changed.
Why not create an npm package?
I've considered removing mitt and creating a react-mini-event-bus npm package. However, for now, the simplicity of maintaining ~50 lines of straightforward code works for me.
Plus, I need to figure out how to make IEventsMap configurable without requiring package users to redefine function signatures. If anyone has suggestions on accomplishing this, I'm all ears. 🗣️👂
What about React Server Components (RSC)?
Lately, everyone has asked about RSC whenever React is mentioned.
I have yet to use them, as they don't address any issues I currently face. I'm not opposed to them.
Conclusion
The eventBus has significantly simplified several subsystems in my projects. However, as with every pattern, it should be used with care.
If you have any questions or comments, you can ping me on Threads, LinkedIn, Mastodon, Twitter or just leave a comment below 📭