PubSub in JavaScript: Intro to Design Patterns
When we are talking about design patterns, we are referring to a solution to a commonly occurring problem in software development.
Patterns are about reusability which helps us resolve architectural problems that can occur throughout our program, regardless of the language it is implemented in. Therefore they are not tied to JavaScript specifically, they are language independent.
Design patterns are categorized into three different types. These are called Creational, Structural and Behavioural patterns. We have a ton of different ones in each type. The one we are interested in this article is called Publish-Subscribe or PubSub for short, also more commonly referred to as Observer.
They are often used interchangeably but we have to differentiate between the two because they are not exactly the same. But before diving into the differences, let’s see what kind of problem is it supposed to solve?
The Problem
The problem arises when we have different modules throughout our application and we want to share some data between them, yet we also don’t want them to be directly dependent on each other. We need a link to create a communication channel.
To give you a practical example, say we have an e-commerce site where products are being sold. We have filtersModule.js
which handles the filtering functionality and this is displayed on multiple pages; listView.js
and gridView.js
. Whenever the filters are updated we want to notify both files about the change so we can update the list of products accordingly. This can be solved in many ways, one of them being the use of PubSub or Observer pattern. Now back to the differences.
Observer vs PubSub
To understand the differences between the two, we need to go over some naming convention first.
Let’s start with the Observer. In the observer pattern, we have a subject which keeps track of its dependents, called observers. The subject can notify them of any state change and observers in return can execute blocks of code for us.
In PubSub, we have a sender called publisher and unlike in the Observer pattern, they don’t hold any reference to their observers, called subscribers, nor they are programmed to send messages directly to predefined receivers.
To emphasis the core difference between the two: in the Observer pattern, observers and subjects are aware of each other and they are linked together. Specific messages are being sent to specific observers, while in PubSub, publishers and subscribers don’t need to know about each other. Any subscription can be made to any published event. In terms of flexibility, PubSub is more compliant.
If you are interested in more design patterns, there’s a really neat collection of them in dofactory.com with definitions, diagrams, and in-depth examples.
The Blueprint
Let’s dive into coding. First, let’s see what we want as an end result.
// Publish an event, whenever a filter is changed and pass the changed data
EventService.publish('filterUpdated', data);
Whenever a filter is changed, we want to publish an event inside filtersModule.js
called filterUpdated
, passing in a data
object that contains all data related to changed filters.
EventService.subscribe('filterUpdated', (data) => {
console.log('Filters have been updated with', data);
});
// Later in our application, whenever we navigate to a different view that don't display the filters
// we can stop listening for any change.
EventService.unsubscribe('filterUpdated');
Inside gridView.js
we listen to this event by subscribing to it and we want to execute a callback function where we operate on the received data that is being published. Later in our application, whenever the user navigates to a page that doesn’t have filtering, we also want to remove the subscription by unsubscribing from the filterUpdated
event. That being said, we can already see what methods we are going to need.
The Skeleton
It’s clear from the end result that we want an EventService
object, with three different methods; publish
, subscribe
and unsubscribe
. But how will these three methods work together logically to create the behavior that we want?
Basically we want to have a list of subscriptions where we keep track of every subscribe
event. We also want to have an associated callback
function, which should be passed as a second parameter to EventService.subscribe()
. Whenever we call publish with the same event name, we simply execute the function associated with it. Unsubscribing from an event simply means we want to remove it from our list of subscriptions. To keep track of every event, we are going to use an additional property. Let’s call it subscriptions
.
With that in mind, let’s lay out the base structure:
const EventService = {
// List of subscriptions currently in use
subscriptions: {},
/**
* @method publish
*
* @param event { string } - name of the event to publish
* @param data { any } - optional data to pass on to subscription
*
* @example
* - event.publish('event');
* - event.publish('event', { data: 'customData' });
* - event.publish('event event1 event2', { data: 'customData' });
*/
publish(event, data?) {
},
/**
* @method subscribe
*
* @param event { string } - name of the event to subscribe to
* @param callback { function } - function callback to execute once the event has been published
*
* @example
* - event.subscribe('event', function () { ... });
* - event.subscribe('event event1 event2', function (data) { ... });
* - event.subscribe('event.namespaced', function (data) { ... });
*/
subscribe(event, callback) {
},
/**
* @method unsubscribe
*
* @param event { string } - name of the event to unsubscribe from
*
* @example
* - event.unsubscribe('event');
* - event.unsubscribe('event event1 event2');
* - event.unsubscribe('event.namespaced');
*/
unsubscribe(event) {
}
};
module.exports = EventService;
Just to make it a little bit more flexible, say we also want to handle multiple event subscriptions and events with namespaces. Namespacing an event can be useful when we have multiple subscriptions to the same event, but we want different functionality in each subscriber and we also want to easily keep track of them. You can see this functionality used in the examples above the subscribe
method.
From now on, we can start implementing each method one by one, starting with probably the most difficult piece, which is the handling of subscribing to events.
Subscribing to Events
Whenever we do a subscription, all we want to do is create a new property with the name of the event inside our subscriptions
object. Starting with some simple declarations and prechecks:
subscribe(event, callback) {
const events = event.split(' ');
let eventArray = [];
if (!callback) {
throw new Error('callback is missing from subscribe: ' + event);
}
}
We first need to grab the events from the parameter. As seen from the examples, we can expect it to be a list of events separated by white spaces. So to collect all events we simply split the event
argument by a white space. We also need to take care of namespaced events. For that we are going to use the eventArray
variable. Lastly, we do a small check; the callback is mandatory so if there’s none provided, we are going to throw an error.
The next step is to actually loop through our events
array and create a node under subscriptions
with the event name itself.
// That means in case of
EventService.subscribe('event event1 event2.firstEvent event2.secondEvent', () => {...});
// We are expecting EventService.subscriptions to be
{
event: [
{ callback: ƒ, namespace: undefined }
],
event1: [
{ callback: ƒ, namespace: undefined }
],
event2: [
{callback: ƒ, namespace: 'firstEvent'}
{callback: ƒ, namespace: 'secondEvent'}
]
}
// Where the callback is the function passed as the second parameter to EventService.subscribe
In case of no namespace provided, we can leave its value to be undefined
. Based on that, we can extend our subscribe method with the following:
subscribe(event, callback) {
const events = event.split(' ');
let eventArray = [];
if (!callback) {
throw new Error('callback is missing from subscribe: ' + event);
}
for (const singleEvent of events) {
if (singleEvent.indexOf('.') > -1) {
eventArray = singleEvent.split('.');
}
if (!this.subscriptions[eventArray[0] || singleEvent]) {
this.subscriptions[eventArray[0] || singleEvent] = [];
}
for (const currentInstance of this.subscriptions[eventArray[0] || singleEvent]) {
if (currentInstance.namespace === eventArray[1]) {
return;
}
}
}
}
Again we do some precautions. We first check on line:10 if the current index is a namespaced event. In that case, we can fill our eventArray
variable with it, eg.: event.namespace
becomes ['event', 'namespace']
.
Next, we need to check whether we already have a subscription to the event. This is what we do on line:14. If there’s none, we create an empty array for it. For the name we either use eventArray[0]
, which holds the base name for a namespaced event, or in case it is undefined, we use singleEvent
. Now whenever we do subscribe('event')
we should get an event
property on the subscriptions
object.
As a last step from line:18, we want to make sure that namespaces are unique and they are not overridden by mistake. By looping through our subscriptions list and checking if one of the instances namespace matches the one in our passed event
parameter, we can eliminate the following case:
// Creating a namespaced event
EventService.subscribe('loaded.custom', () => {
console.log('call has been made');
});
// Later in our application we accidentally use the same namespace for the same subscription
EventService.subscribe('loaded.custom', () => {
console.log('call has been overriden');
});
// We then publish the event
EventService.publish('loaded');
// output will still be "call has been made"
All that’s left to do is to create the subscription’s body, which can be done in 3 lines of code:
subscribe(event, callback) {
const events = event.split(' ');
let eventArray = [];
if (!callback) {
throw new Error('callback is missing from subscribe: ' + event);
}
for (const singleEvent of events) {
if (singleEvent.indexOf('.') > -1) {
eventArray = singleEvent.split('.');
}
if (!this.subscriptions[eventArray[0] || singleEvent]) {
this.subscriptions[eventArray[0] || singleEvent] = [];
}
for (const currentInstance of this.subscriptions[eventArray[0] || singleEvent]) {
if (currentInstance.namespace === eventArray[1]) {
return;
}
}
this.subscriptions[eventArray[0] || singleEvent].push({
callback,
namespace: eventArray[1]
});
eventArray = [];
}
}
This is what we have done from line:24 till line:27. We create a new object and give it a callback
property which will be the callback provided in the arguments of the function. We also give it a namespace
property which will either be undefined
or the namespace we provided for the event. As a last step, we empty out the eventArray
variable, so we can reuse it in the next iteration. Now we can start moving onto the implementation of the unsubscribe functionality.
Unsubscribing From Events
Just as for subscribing, we can start with the same base for unsubscribing:
unsubscribe(event) {
const events = event.split(' ');
let eventArray;
for (const currentEvent of events) {
eventArray = currentEvent.split('.') || currentEvent;
}
}
We first create an events
array, based on the event
parameter and we also declare an eventArray
variable, without any value this time. We then start looping over our events and assign the current event name to eventArray
at each iteration.
Next, we need to check whether the subscription exists and if so, we need to loop through the event, since we can have multiple subscriptions to the same event. This leaves us with:
unsubscribe(event) {
const events = event.split(' ');
let eventArray;
for (const currentEvent of events) {
eventArray = currentEvent.split('.') || currentEvent;
if (this.subscriptions[eventArray[0]]) {
for (let j = 0; j < this.subscriptions[eventArray[0]].length; j++) {
}
}
}
}
On line:8 we check if the event we want to unsubscribe from actually exists. Since we split the event
argument we got on line:2, we can ensure that currentEvent
will be an array, hence we are referring to the event name as eventArray[0]
, even if we don’t use split on the currentEvent
variable. After that, we can start looping on it. This is needed as we might only want to remove a namespaced instance and not the whole subscription itself. To actually check whether we are about to remove a namespaced event or not, we need an if statement to check if eventArray[1]
exists. If it is, we know we are unsubscribing from a namespaced event.
unsubscribe(event) {
const events = event.split(' ');
let eventArray;
for (const currentEvent of events) {
eventArray = currentEvent.split('.') || currentEvent;
if (this.subscriptions[eventArray[0]]) {
for (let j = 0; j < this.subscriptions[eventArray[0]].length; j++) {
if (eventArray[1]) {
// Logic for unsubscribing from namespaced event
} else {
delete this.subscriptions[eventArray[0]];
break;
}
}
}
}
}
Otherwise, we can just delete the node in this.subscriptions
. We also need to break
after that to jump out from the loop. All that’s left to do is to check for the namespace
of the current subscription’s instance, and if it matches with the value inside eventArray[1]
, we just splice the array at the index of j
. This is what has been done on line:13.
unsubscribe(event) {
const events = event.split(' ');
let eventArray;
for (const currentEvent of events) {
eventArray = currentEvent.split('.') || currentEvent;
if (this.subscriptions[eventArray[0]]) {
for (let j = 0; j < this.subscriptions[eventArray[0]].length; j++) {
if (eventArray[1]) {
if (this.subscriptions[eventArray[0]][j].namespace === eventArray[1]) {
this.subscriptions[eventArray[0]].splice(j, 1);
}
if (!this.subscriptions[eventArray[0]].length) {
delete this.subscriptions[eventArray[0]];
break;
}
} else {
delete this.subscriptions[eventArray[0]];
break;
}
}
}
}
}
I also included another if statement, starting from line:16. Its job is to determine if there’s only one instance left for a given subscription. If we are deleting that sole instance, we can essentially remove the whole event itself, meaning:
// The contents of this.subscriptions
{
loaded: [
{ callback: ƒ, namespace: 'custom' }
]
}
// Calling it will remove `this.subscriptions.loaded` altogether
// Since we don't have any other instances
EventService.unsubscribe('loaded.custom');
As a last step, all we have to do now is implement the functionality for publishing our events so their corresponding callback function can be fired.
Publishing Events
If you made it this far, congratulations, you are over the hard part! 🎉Probably the simplest piece in the whole equation is publishing events. All we have to do is loop through our subscription
list and if we find the passed in event’s name, we execute its callback property with the passed in data.
publish(event, data?) {
for (const key in this.subscriptions) {
if (this.subscriptions.hasOwnProperty(key)) {
if (key === event) {
for (const currentInstance of this.subscriptions[key]) {
currentInstance.callback(data);
}
}
}
}
}
For looping, we can use a simple for ... in
statement. To make sure we only iterating through its own properties, we can wrap everything inside an if. This is what line:3 is supposed to do. Then on line:4, we can check if the passed in event matches with the current key and if so, call the callback
property and pass in data
that we get from the second argument of the function.
If everything is done correctly, running EventService.publish('filterUpdated', data);
in your console should give you a console.log
with the passed in data immediately.
By now, you should have a solid understanding of how a Publish-Subscribe behavioral design pattern can be achieved. Congratulations for reaching this far! 🙌 Before closing off, I would like to give you some advice about design patterns in general.
Things to Keep in Mind
First and foremost, don’t make patterns a hammer looking for nails. What can be solved without introducing a new pattern, should be solved.
Everything comes with a trade-off, the same applies to patterns as well. While it can help you in a lot of cases, on the downside, it increases your code complexity and your overall bundle size.
You should always strive to keep your code as simple as possible, (Remember the KISS principle — Keep it simple, stupid) and only use a pattern when you see a need for it.
If you do, however, don’t reinvent the wheel, use an already existing pattern as a solution. Now I’ll go ahead and publish this story. ☕️
Rocket Launch Your Career
Speed up your learning progress with our mentorship program. Join as a mentee to unlock the full potential of Webtips and get a personalized learning experience by experts to master the following frontend technologies: