Seong-Hyun Ryoo!
A chibi representation of Seong-Hyun writing the blog post on this page.

Event-based architecture in React with TypeScript

Wednesday, May 8, 2024

Background

Some time ago, I started implementing an event-based architecture for one of the React applications at my job. This particular React app is a large, complex project that relies heavily on useEffect in order to control the flow of the data. A great many of our components have useEffects which watch for changes in the underlying application state and perform actions upon said changes. This approach does have certain benefits, such as a relatively simple syntax and the ability to tie re-rendering with changes in application state.

However, it does have a number of drawbacks. Some include the issue of concurrency/race conditions (e.g., if two separate components have useEffect hooks monitoring the change in the same stored value, then which component will execute its response first?) and difficulty debugging. But most of all, I think the utilization of useEffect to control the flow of data in our application results in a problem of comprehensibility.

Often, our application chains useEffects together. For instance, a change in the application state will trigger a useEffect callback in Component A, which will then perform certain transformations to the application state. This transformation then triggers a useEffect callback in Component B, which modifies the application state further, and so on. There is no clear start or end to our data flow, and there is no straightforward way to identify the next or previous step in the useEffect chain. Visualizing in your head the useEffect-based data flow of our application often results in the image of the application state ricocheting off of component to component like a pinball shooting between bumpers. Not only does this make work more time-consuming for developers already familiar with the codebase, it also ends up being a large hurdle for developers who are just joining the project, as a holistic understanding of our pinball-machine data flow must often be built up through gradual exposure to different components in the applicaiton.

The alternative: an event-based architecture

In order to address this issue, I started to implement a simple publish-subscribe event system which will be able to perform the same duties as our useEffect-driven data flow without the complex nature of useEffect chaining. To set up this system, we need to first identify what we mean by an "event."

What is an Event?

For our purposes, an Event is a specific, named occurence which happens within the lifecycle of a React application. An Event has an ID and a collection of callbacks, which are all invoked upon the dispatching of the Event. An Event also has the ability to pass a data package to its callback functions upon dispatching.

A warning!

This event-based system does have some limitations. For me, the most glaring has been the difficulty in accessing state and store variables from within a callback. For instance, consider a rather innocuous-looking snippet of code in which we try to access the value of a useState variable from within a callback function.

const [count, setCount] = useState(0);

// some code in which setCount(100) is invoked

const onEventDispatch = () => {
  console.log(count); // always 0!
};

If we were to subscribe to one of our events with the onEventDispatch callback, then the value of count would always be 0. There are two ways to handle this issue: the first is to favor passing in data through the callback parameter (more on this later), which always ensures that you have access to the correct data, bypassing many concurrency issues. Another way to handle this is by wrapping the useState variable in a useRef.

const [count, setCount] = useState(0);
const statelessCount = useRef<number>(count);

// some code in which setCount(100) is invoked

const onEventDispatch = () => {
  console.log(statelessCount.current); // prints 100
};

Before: a bit of code using useEffect

Let's begin by observing a React component which watches a store value (score) and responds to any changes in said value. When a change to score (the underlying application state) occurs, our component will print out a message to the console using the most recent value of score. Here is the component with its behavior implemented via the useEffect pattern:

export const ScoreWatcher = () => {
  // obtain value of score from a store
  useEffect(() => {
    console.log("Game over. Score: " + score);
  }, [score]);
};

This is simple enough, but imagine ten or twenty more different components which all respond to a change in the score value. At a certain project scale, it becomes very difficult to follow the flow of the data.

After: implementing the event system

Defining the LifeEvent class

Implementing the event system will require some more setup on our end. First, let's begin by creating an enum which encapsulates all the different possible events which can occur in the lifecycle of our application. This does not have to be exhaustive, as you are meant to expand this list whenever you need to create a new linked behavior. In our case, the event we want to capture is a change in the score.

enum LifeEventIds {
  SCORE_CHANGED = "Score changed",
  // I like to use string enums since it is helpful for logging,
  // but that part is optional.
}

Then, let's define our LifeEvent type itself. Our LifeEvent object will have a unique ID and a collection of callbacks to invoke when dispatched.

class LifeEvent {
  id: LifeEventIds;
  // We use a key-value Object for our callback collection
  // as this allows us to quickly query a callback collection for
  // the specific listenerId, which is needed for unsubscribing a listener.
  callbacks: {
    [listenerId: string]: (data: LifeEventData) => void;
  };
  constructor(eventId: LifeEventIds) {
    this.id = eventId;
    this.callbacks = {};
  }
}

Sending data packages on dispatch with LifeEventData

You may have noticed that the callbacks have a parameter of type LifeEventData. This represents the data package which can be sent by a LifeEvent to its callback functions. For this implementation, we want a system which will allow us to define the structure of the LifeEventData for each different LifeEvent. For our LifeEventIds.SCORE_CHANGED event, the most relevant piece of data would likely be the current score.

export const LifeEventData = {
  // There is an entry for each possible event
  [LifeEventIds.SCORE_CHANGED]: {
    score: number,
  },
};

The event emitter

Now, let's create an object which can store all the LifeEvents and perform actions on them (e.g., subscribing, unsubscribing, dispatching). Here is the type definition for such an item.

interface LifeEventEmitterType {
  events: {
    [eventId in LifeEventIds]: LifeEvent;
  };
  sub: (
    eventId: LifeEventIds,
    listenerId: string,
    callback: (data: LifeEventData) => void
  ) => void;
  unsub: (eventId: LifeEventIds, listenerId: string) => void;
  dispatch: (eventId: LifeEventIds, data: LifeEventData) => void;
}

And then, the actual object itself:

export const LifeEventEmitter = {
  events: {
    [LifeEventIds.SCORE_CHANGED]: new LifeEvent(LifeEventIds.SCORE_CHANGED),
  },
  sub: (eventId, listenerId, callback) => {
    const event = this.events[eventId];
    event.callbacks[listenerId] = callback;
  },
  unsub: (eventId, listenerId) => {
    const event = this.events[eventId];
    delete event.callbacks[listenerId];
  },
  dispatch: (eventId, data) => {
    const event = this.events[eventId];
    for (const listenerId of event.callbacks) {
      const callback = event.callbacks[listenerId];
      if (callback) {
        callback(data);
      }
    }
  },
};

Dispatching an event

Next, let's handle dispatch of the score change event. Dispatching is rather simple. It does not need to be performed within a component, but it can be!

const data: LifeEventData = {
  [LifeEventIds.SCORE_CHANGED]: {
    score: 100,
  },
};
LifeEventEmitter.dispatch(LifeEventIds.SCORE_CHANGED, data);

Subscribing to an event

Finally, let's rewrite our original ScoreWatcher component to use the new event system instead of the useEffect system:

export const ScoreWatcher = () => {
  // obtain value of score from a store
  useEffect(() => {
    const listenerId = "score-watcher";

    const callback: (data: LifeEventData) => void = (data) => {
      const eventData = data[LifeEventIds.SCORE_CHANGED];
      console.log("Game over. Score: " + eventData.score);
    };

    LifeEventEmitter.sub(LifeEventIds.SCORE_CHANGED, listenerId, callback);
    return () => {
      LifeEventEmitter.unsub(LifeEventIds.SCORE_CHANGED, listenerId);
    };
  }, []);
};

Conclusion

There you have it! A relatively simple event system which can hopefully make it a lot easier to manage the data flow of a complex React application. It certainly introduces its own share of challenges, but I think in certain cases the benefits it provides in terms of architectural clarity can be very useful indeed.