Creating a simple custom event system in JavaScript

Creating a simple custom event system in JavaScript

What problem are we solving?

Your front end code can quickly become unwieldy when building an application which has to react to a large variety of different events. For example, in an app using websockets it's not uncommon to see code like this:

mySocket.addEventListener("message", (message) => {
    const data = JSON.parse(message.data);
    switch (data.event) {
        case "NewMessage":
            addMessage(message);
            addNotification(message);
            break;
        case "MessageEdited":
            changeMessage(message);
            someSideEffect();
            break;
            
        // ...etc.
            
    }
});

Needless to say if you start needing to deal with 20, 30, or 40 different events this can start to become very messy, and what if we want to stop listening to a particular event? You'll need to start managing some sort of state and adding confusing conditionals to all of your case statements. Clearly there must be a better way to do things!

What can we do about it?

Ideally we would be able to pass in the event and related data into an event dispatcher which will then call all the correct functions for us. We should also be able to stop certain functions from running - for example if the current window is already open we don't want to create a new notification.

By the end of this tutorial we'll be able to turn the above code into something more like this:

const myDispatcher = new Dispatcher();

// Listen for socket messages
mySocket.addEventListener("message", (message) => {
  // dispatch the appropriate event
  const data = JSON.parse(message.data);
  myDispatcher.dispatch(data.event, data);
});

// Add event listeners for the events that come from the socket
myDispatcher.on("NewMessage", addMessage);
myDispatcher.on("MessageEdited", changeMessage);
myDispatcher.on("MessageEdited", someSideEffect);

// We should also be able to turn event listeners off & back on
window.addEventListener("blur", () => {
    myDispatcher.on("NewMessage", addNotification);
});
window.addEventListener("focus", () => {
    myDispatcher.off("NewMessage", addNotification);
});

This is already starting to feel much more organised and powerful, and the best thing is that we can require this dispatcher from anywhere in our app - letting us group related events into separate files, and manage our events from anywhere. We could also have several dispatchers for different sets of events, or multiple dispatchers for multiple websocket connections. The structure is really up to you.

Let's learn how to build it!

Building the dispatcher

We're going to build this using es6 classes to keep our code tidy and object oriented. Lets start by building out the skeletons of the classes, with each of the methods we will need.

Mapping out the skeletons of the classes

class Dispatcher {
    constructor() {
        this.events = {};
    }

    dispatch(eventName, data) {
        // TODO: dispatch the provided event
    }

    on(eventName, callback) {
      // TODO: add the event listener to the provided event
    }

    off(eventName, callback) {
      // TODO: remove the event listener from the provided event
    }
}

We are initializing Dispatcher.events with an empty object, this is where we will store each of the events that is associated with this dispatcher. Events will also need their own methods, so we'll have to create a DispatcherEvent class for the events.

class DispatcherEvent {
    constructor(eventName) {
        this.eventName = eventName;
        this.callbacks = [];
    }

    registerCallback(callback) {
        // TODO: Add the provided callback to the event
    }

    unregisterCallback(callback) {
        // TODO: Remove the provided callback from the event
    }

     fire(data) {
        // TODO: Call each callback with the provided data
    }
}

The DispatcherEvent class is initialized with the eventName which we pass in, and an empty callbacks array where we will store each callback registered for the event.

Implementing the methods

Now that we have the skeleton of the Dispatcher and DispatcherEvent mapped out, we can start implementing each method. Adding a new event listener seems like a good place to start, so let's start with the Dispatcher.on() method.

on(eventName, callback) {
    // First we grab the event from this.events
    let event = this.events[eventName];
    // If the event does not exist then we should create it!
    if (!event) {
        event = new DispatcherEvent(eventName);
        this.events[eventName] = event;
    }
    // Now we add the callback to the event
    event.registerCallback(callback);
}

We've used DispatcherEvent.registerCallback() here, but we haven't built it yet! Let's build it out next to get Dispatcher.on() working.

registerCallback(callback) {
    this.callbacks.push(callback);
}

All we do here is push the given callback into the event's callback array, simple!

We can now create events and add callbacks, but that's not useful unless we can actually run those callbacks! Let's build out Dispatcher.dispatch() next.

dispatch(eventName, data) {
    // First we grab the event
    const event = this.events[eventName];
    // If the event exists then we fire it!
    if (event) {
      event.fire(data);
    }
}

To get this working we need to implement the DispatcherEvent.fire() method:

fire(data) {
    // We loop over a cloned version of the callbacks array
    // in case the original array is spliced while looping
    const callbacks = this.callbacks.slice(0);
    // loop through the callbacks and call each one
    callbacks.forEach((callback) => {
        callback(data);
    });
}

All we do is loop through and call each callback, easy right? So now we can add event listeners to the dispatcher and dispatch them. All we need to do now is implement removing listeners. Let's start with the Dispatcher.off() method.

off(eventName, callback) {
    // First get the correct event
    const event = this.events[eventName];
    // Check that the event exists and it has the callback registered
    if (event && event.callbacks.indexOf(callback) > -1) {
        // if it is registered then unregister it!
        event.unregisterCallback(callback);
        // if the event has no callbacks left, delete the event
        if (event.callbacks.length === 0) {
            delete this.events[eventName];
        }
    }
}

The only method left to implement is the DispatcherEvent.unregisterCallback() method, so that we can remove the callback from the callbacks array.

unregisterCallback(callback) {
    // Get the index of the callback in the callbacks array
    const index = this.callbacks.indexOf(callback);
    // If the callback is in the array then remove it
    if (index > -1) {
        this.callbacks.splice(index, 1);
    }
}

What have we made?

Phew! We're done. let's see what our finished product looks like.

class DispatcherEvent {
    constructor(eventName) {
        this.eventName = eventName;
        this.callbacks = [];
    }

    registerCallback(callback) {
        this.callbacks.push(callback);
    }

    unregisterCallback(callback) {
        const index = this.callbacks.indexOf(callback);
        if (index > -1) {
            this.callbacks.splice(index, 1);
        }
    }

     fire(data) {
        const callbacks = this.callbacks.slice(0);
        callbacks.forEach((callback) => {
            callback(data);
        });
    }
}

class Dispatcher {
    constructor() {
        this.events = {};
    }

    dispatch(eventName, data) {
        const event = this.events[eventName];
        if (event) {
            event.fire(data);
        }
    }

    on(eventName, callback) {
        let event = this.events[eventName];
        if (!event) {
            event = new DispatcherEvent(eventName);
            this.events[eventName] = event;
        }
        event.registerCallback(callback);
    }

    off(eventName, callback) {
        const event = this.events[eventName];
        if (event && event.callbacks.indexOf(callback) > -1) {
            event.unregisterCallback(callback);
            if (event.callbacks.length === 0) {
                delete this.events[eventName];
            }
        }
    }
}

Wrapping up

That's a small amount code for a lot of functionality! For the purposes of this tutorial we've kept things relatively simple, there are a couple of things to bear in mind:

We haven't done any error handling
We haven't checked that the proper arguments are passed to the methods
We haven't optimised for performance

So there we are! Think about what other functionality you could add to this to make it even more powerful, and don't limit your imagination to using this with websockets - it could be great for a variety of different situations!