I recently came across an interesting use case of Event Bus at work. It’s a very lean module to organize logging for analytics in a global-scale web application. It creates great clarity in a large code base so I want to share with you my case study of this useful design pattern.
Let’s go.
An Event Bus is a design pattern that allows PubSub-style communication between components while the components remain loosely coupled.
A component can send a message to an Event Bus without knowing where the message is sent to. On the other hand, a component can listen to a message on an Event Bus and decide what to do with the message without knowing where the message comes from. With this design, independent components can communicate without knowing each other.
The visualization looks like this:
Event: The message being sent and received on an Event Bus.
Publisher: The sender that emits an event.
Subscriber: The receiver that listens to an event.
Let’s take a closer look at Event Bus.
Inspired by Vue’s legacy Events API, we’ll implement the following APIs for our Event Bus:
type EventHandler = (payload: any) => void
interface EventBus {
on(key: string, handler: EventHandler): () => void
off(key: string, handler: EventHandler): void
emit(key: string, ...payload: Parameters<EventHandler>): void
once(key: string, handler: EventHandler): void
}
Now, the data structure for our Event Bus should be capable of two things:
We can use a key-value structure for it:
type Bus = Record<string, EventHandler[]>
To implement the on method, all we need to do is to add the event key to the bus and append the event handler to the handler array. We also want to return an unsubscribe function to remove the event handler.
export function eventbus(config?: {
// error handler for later
onError: (...params: any[]) => void
}): EventBus {
const bus: Bus = {}
const on: EventBus['on'] = (key, handler) => {
if (bus[key] === undefined) {
bus[key] = []
}
bus[key]?.push(handler)
// unsubscribe function
return () => {
off(key, handler)
}
}
return { on }
}
To implement off, we can simply remove the event handler from the bus.
const off: EventBus['off'] = (key, handler) => {
const index = bus[key]?.indexOf(handler) ?? -1
bus[key]?.splice(index >>> 0, 1)
}
When emit is called, what we want to do is to fire all the event handlers that associate with the event. We’ll add error handling here to make sure all the event handlers will be fired despite errors.
const emit: EventBus['emit'] = (key, payload) => {
bus[key]?.forEach((fn) => {
try {
fn(payload)
} catch (e) {
config?.onError(e)
}
})
}
Since once will only listen to an event exactly once, we can think of it as a method that registers a handler that deregisters itself after firing. One way to do it is to create a higher order function handleOnce:
const once: EventBus['once'] = (key, handler) => {
const handleOnce = (payload: Parameters<typeof handler>) => {
handler(payload)
off(key, handleOnce as typeof handler)
}
on(key, handleOnce as typeof handler)
}
Now we have all the methods in our Event Bus!
The current typing for the Event Bus is quite open-ended. The event key could be any string and the event handler could be any function. To make it safer to use, we can add type checking to add the event key and handler association to the EventBus.
type EventKey = string | symbol
type EventHandler<T = any> = (payload: T) => void
type EventMap = Record<EventKey, EventHandler>
type Bus<E> = Record<keyof E, E[keyof E][]>
interface EventBus<T extends EventMap> {
on<Key extends keyof T>(key: Key, handler: T[Key]): () => void
off<Key extends keyof T>(key: Key, handler: T[Key]): void
once<Key extends keyof T>(key: Key, handler: T[Key]): void
emit<Key extends keyof T>(key: Key, ...payload: Parameters<T[Key]>): void
}
interface EventBusConfig {
onError: (...params: any[]) => void
}
export function eventbus<E extends EventMap>(
config?: EventBusConfig
): EventBus<E> {
const bus: Partial<Bus<E>> = {}
const on: EventBus<E>['on'] = (key, handler) => {
if (bus[key] === undefined) {
bus[key] = []
}
bus[key]?.push(handler)
return () => {
off(key, handler)
}
}
const off: EventBus<E>['off'] = (key, handler) => {
const index = bus[key]?.indexOf(handler) ?? -1
bus[key]?.splice(index >>> 0, 1)
}
const once: EventBus<E>['once'] = (key, handler) => {
const handleOnce = (payload: Parameters<typeof handler>) => {
handler(payload)
// TODO: find out a better way to type `handleOnce`
off(key, handleOnce as typeof handler)
}
on(key, handleOnce as typeof handler)
}
const emit: EventBus<E>['emit'] = (key, payload) => {
bus[key]?.forEach((fn) => {
try {
fn(payload)
} catch (e) {
config?.onError(e)
}
})
}
return { on, off, once, emit }
}
Now we are instructing TypeScript that the key has to be one of the keyof T and the handler should have the corresponding handler type. For example:
interface MyBus {
'on-event-1': (payload: { data: string }) => void
}
const myBus = eventbus<MyBus>()
You should be able to see a clear type definition when developing.
I created a Remix application to demonstrate how to use the Event Bus we just built.
You can find the GitHub repository for the demo here.
The demo showcases how to organize logging with the Event Bus in an isomorphic React application. I picked three events to log:
Let’s create two event channels. One for the map, and one for the marker.
import { eventbus } from 'eventbus'
export const mapEventChannel = eventbus<{
onMapIdle: () => void
onMapClick: (payload: google.maps.MapMouseEvent) => void
}>()
import { eventbus } from 'eventbus'
import type { MarkerData } from '~/data/markers'
export const markerEventChannel = eventbus<{
onMarkerClick: (payload: MarkerData) => void
}>()
The reason to separate event channels is to create a clear separation of concerns. This pattern can grow horizontally when your application grows.
Now, let’s use the event channels in React components.
import { markers } from '~/data/marker'
import { logUserInteraction } from '~/utils/logger'
import { mapEventChannel } from '~/eventChannels/map'
import { markerEventChannel } from '~/eventChannels/marker'
export async function loader() {
return json({
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
})
}
export default function Index() {
const data = useLoaderData()
const portal = useRef<HTMLDivElement>(null)
const [selectedMarker, setSelectedMarker] = useState<MarkerData>()
useEffect(() => {
// subscribe to events when mounting
const unsubscribeOnMapIdle = mapEventChannel.on('onMapIdle', () => {
logUserInteraction('on map idle.')
})
const unsubscribeOnMapClick = mapEventChannel.on(
'onMapClick',
(payload) => {
logUserInteraction('on map click.', payload)
}
)
const unsubscribeOnMarkerClick = markerEventChannel.on(
'onMarkerClick',
(payload) => {
logUserInteraction('on marker click.', payload)
}
)
// unsubscribe events when unmounting
return () => {
unsubscribeOnMapIdle()
unsubscribeOnMapClick()
unsubscribeOnMarkerClick()
}
}, [])
const onMapIdle = (map: google.maps.Map) => {
mapEventChannel.emit('onMapIdle')
setZoom(map.getZoom()!)
const nextCenter = map.getCenter()
if (nextCenter) {
setCenter(nextCenter.toJSON())
}
}
const onMapClick = (e: google.maps.MapMouseEvent) => {
mapEventChannel.emit('onMapClick', e)
}
const onMarkerClick = (marker: MarkerData) => {
markerEventChannel.emit('onMarkerClick', marker)
setSelectedMarker(marker)
}
return (
<>
<GoogleMap
apiKey={data.GOOGLE_MAPS_API_KEY}
markers={markers}
onClick={onMapClick}
onIdle={onMapIdle}
onMarkerClick={onMarkerClick}
/>
<Portal container={portal.current}>
{selectedMarker && <Card {...selectedMarker} />}
</Portal>
<div ref={portal} />
</>
)
}
What we did was subscribe to the events in the Index component and emit the events when the map and markers interacted. Moreover, By subscribing and unsubscribing with the component’s lifecycle, we are able to register only the necessary event handlers at the given moment of the user journey.
If you are looking for an Event Bus library, there are a couple of choices recommended by Vue.js:
There’s also an interesting discussion on Reddit about using Redux as an Event Bus. One of the maintainers suggested a few Redux-based tools to handle events:
This article is originally posted onhere.