paint-brush
Writing an Event Bus for Reactby@dawchihliou
2,603 reads
2,603 reads

Writing an Event Bus for React

by Daw-Chih LiouAugust 2nd, 2022
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

🚌 We’ll write a lightweight Event Bus from scratch in just 60 lines! 🌱 We’ll learn the use case to best utilize Event Bus in React. 🏋️‍♀️ We’ll apply Event Bus in a demo with Google Maps API.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Writing an Event Bus for React
Daw-Chih Liou HackerNoon profile picture

TL;DR

  • 🚌 We’ll write a lightweight Event Bus from scratch in just 60 lines!
  • 🌱 We’ll learn the use case to best utilize Event Bus in React.
  • 🏋️‍♀️ We’ll apply Event Bus in a demo with Google Maps API.




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.

What Is An Event Bus?

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.

Building An Event Bus from Scratch

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
}


  • on: for a subscriber to listen (subscribe) to an event and register its event handler.
  • off: for a subscriber to remove (unsubscribe) an event and its event handler.
  • once: for a subscriber to listen to an event only once.
  • emit: for a publisher to send an event to the event bus.


Now, the data structure for our Event Bus should be capable of two things:


  • For publishers: to be able to fire the registered event handlers associated with the event key when emit is called.
  • For subscribers: to be able to add or remove event handlers when ononce, or off is called.


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!

Improve TypeScript Typing

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.


Use The Event Bus in React

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:


  • onMapIdle: the event happens when the map finishes instantiating or a user finishes dragging or zooming the map.
  • onMapClick: the event happens when a user clicks on the map.
  • onMarkerClick: the event happens when a user clicks on a map marker.


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.

Final Thoughts

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:


  • redux-toolkit’s new Listener Middleware
  • redux-observable
  • Redux-Saga

References




This article is originally posted onhere.