Build a Pub-Sub in JavaScript | Skilled.dev
Interview Question

Pub-Sub and Event-Driven Programming

Course launching November 17, 2020

Follow me on YouTube for free coding interview videos.

Users who sign up for the email list will receive an exclusive 75% discount at launch.

Your subscription could not be saved. Please try again.
Your subscription was successful! 🤓
Loading...

Event-driven programming is incredibly important in software engineering, and it's the foundation of JavaScript (both in the browser and Node.js). Events are emitted on interaction either by a user or triggered from within the program, and there are listeners that execute a callback when they recognize a specific event.

You can see it in:

  • Browser events (onclick, onload, onkeyup, etc.)
  • Node.js server events
  • Sockets
  • Messaging
  • Redux and other state management libraries
  • RxJS and other event libraries
  • GraphQL subscriptions

There are different names for the varius event design patterns, but the core concept is the same. In this lesson we will build a Pub-Sub, but the intuition will be transferable. Other similar concepts are:

  • Observer pattern
  • Event bus
  • Message queue
  • Event-driven architecture

There are subtle differences between the different pattern implementations, but the important learning outcomes are all the same. In this question you will build a Pub-Sub (publisher/subscriber pattern) where publishers emit actions and subscribers listen for a specific action and execute a callback accordingly.

We will implement the Pub-Sub as a class with subscribe, publish, unsubscribe methods. They will communicate through an eventName channel which is just a string. It should be built so that many subscribers can listen for the same event.

class PubSub {
  constructor() {
    this.subscribers = {};
  }

  subscribe(eventName, callback) {
    // Subscribe callback to an eventName
  }

  publish(eventName, data) {
    // Execute callbacks for eventName
  }

  unsubscribe(eventName, callback) {
    // Remove a previously added callback
  }
}

Then to use it, we do the following:

const pubSub = new PubSub();

// Example as browser event
pubSub.subscribe('click', (eventData) => console.log('Use received a click!'));

pubSub.publish('click', { target: 'node' });

// Example as error captured
pubSub.subscribe('error', (errorData) => pageTeamWithError(errorData));
pubSub.subscribe('error', (errorData) => storeErrorDetails(errorData));

pubSub.publish('error', { message: 'It broke' });

Pub-Sub Implementation

Our Pub-Sub implementation will give us the ability to subscribe to an event, publish data to an event, and a place to store the subscribed callbacks. In the constructor, we initialized a subscriptions object whose key-value pairs will be the event channel which maps to an array of event callbacks.

subscribe method

The first thing we should implement is the subscribe method. The function takes an eventName which is a string, and a callback which is a function that executes with the data.

subscribe(eventName, callback) {
  // Initialize a channel if it doesn't exist yet
  if (!Array.isArray(this.subscribers[eventName])) {
    this.subscribers[eventName] = [];
  }

  // Add callback to the channel
  this.subscribers[eventName].push(callback);
}

First we check if we have initialized an array of callbacks in subscribers for the eventName channel, and we do so if it is not. Then we push the callback to this array which is a function that takes a single argument.

publish method

When we want to trigger and event, we publish under the eventName and pass it the data for that event. Our callbacks should take at-most one argument, but could take no arguments if it does not use the data.

In our case, the data can be anything, but you could enforce it to be something specific either in your Pub-Sub impelementation or by the callback for a specific eventName.

When we publish and event, we first check that the eventName has been initialized and return early if it has not. If it does exist, we iterate through a all the subscribers for that eventName and execute their callbacks.

subscribe(eventName, callback) {
  // Initialize a channel if it doesn't exist yet
  if (!Array.isArray(this.subscribers[eventName])) {
    this.subscribers[eventName] = [];
  }

  // Add callback to the channel
  this.subscribers[eventName].push(callback);
}

unsubscribe method

We want to allow our code to clean-up a subscription and remove it if it is no longer using it. We pass an eventName and the same callback from the subscribe method to remove it. Then the callback is filtered out of our subscribers for that channel.

Functions are stored in memory as in JavaScript. You mast pass the exact same function you used originally which means you'll need to store it in a variable. See how this is done in the solution REPL.

unsubscribe(eventName, callback) {
  if (Array.isArray(this.subscribers[eventName])) {
    // Keeps all the callbacks that aren't equal to the one we unsubscribe
    this.subscribers[eventName] = this.subscribers[eventName].filter(cb => cb !== callback);
  }
}

Full Implementation

Interact with this solution in a REPL.

class PubSub {
  constructor() {
    this.subscribers = {};
  }

  subscribe(eventName, callback) {
    // Initialize a channel if it doesn't exist yet
    if (!Array.isArray(this.subscribers[eventName])) {
      this.subscribers[eventName] = [];
    }

    // Add callback to the channel
    this.subscribers[eventName].push(callback);
  }

  publish(eventName, data) {
    // Return if no callbacks have been subscribed to an event
    if (!Array.isArray(this.subscribers[eventName])) {
      return;
    }

    // Execute all the callbacks subscribed to an event
    // Pass the callback the data from the event
    this.subscribers[eventName].forEach((callback) => {
      callback(data);
    });
  }

  unsubscribe(eventName, callback) {
    if (Array.isArray(this.subscribers[eventName])) {
      // Keeps all the callbacks that aren't equal to the one we unsubscribe
      this.subscribers[eventName] = this.subscribers[eventName].filter(cb => cb !== callback);
    }
  }
}

Wrap Up

The publisher/subscriber pattern is actually simple to implement, but the usage is very powerful. This is a common JavaScript interview question because it shows an understanding of an essential pattern and knowledge of essential JS concepts.

One challenge developers have with a Pub-Sub is the terminology because it makes it sound much more challengint than it actually is. When we say a subscriber is listenting, all this means is that we have a hash table (object) that has an array of events for a given key. A subscriber adds its callback to this array, and the publisher just executes all the callback under that key when it emits its events.

If you have used Redux, what's happening underneath the hood is almost the exact same. The only difference is that we are storing state insider our Pub-Sub, and the eventName (action.type now) is passing the action to every reducer, and they decide if they should update a piece of state based on the action. Below is a simple implementation with phrases changed to Redux terminology:

// type is equivalent to eventName
// payload is the event data
const buildAction = (type, payload) => ({ type, payload })

const createStore = (reducer, initialState) => {
  // Track state of app inside an object
  const store = {};
  store.state = initialState;
  // Same as subscriptions
  store.listeners = [];

  // Return the app's state
  store.getState = () => store.state;

  // Add a callback subscription
  store.subscribe = listener => {
    store.listeners.push(listener);
  };

  // Publish an action (same as an event)
  store.dispatch = action => {
    console.log('> Action', action);
    // Pass the action to the reducer where it determines
    // how to handle the event based on the action.type
    store.state = reducer(store.state, action);

    // Execute all the subscriptions which will
    // update the UI based on the new state
    store.listeners.forEach(listener => listener());
  };

  // Returns a store object which holds state, publish, and subscribe functionality
  return store;
};

For larger system Pub-Subs, message queues, and observers, it requires more robust storage and communication channels, but the fundamentals are still the exact same. Store channel names and a list of functions to execute

Prev
Build a JavaScript Promise
Next
Debounce
Loading...

Table of Contents