Share options

Links to related pages

Essays about code. Essays about connection. Essays about context. Critiques of coding practices.

Adding simple PubSub, part 1

PubSub is a great way to decouple components.

Time to read

, 1037 words, 4th grade

One of the most important Craft Code principles is loose coupling (or decoupling). If we organize our code into loosely-coupled modules, then we can swap them out with ease. We can refactor them, update them, reduce tech debt.

In general, the more we can split our code into simple modules, the better. We can treat them as black boxes. But then we will need a way for them to communicate.

An excellent way is via an event bus, or a publish-subscribe (PubSub) system. This is a tried-and-true design pattern. It has been around since, hmm, Amenhotep (Egyptian dude). Or thereabouts.

We can build a simple pubsub module in JavaScript. Then we can use that to connect our components to each other. And we can do it without any component having to “know” any other component. Thatʼs loose coupling.

So letʼs get to it. And in successive parts, weʼll see how it all fits together.

Our simple pubsub module

First, weʼre gonna need a place to put our subscriptions. This should work:

Our oh-so-complex subscriptions module.
export default {}

This example is as simple as it gets: export default empty object literal.

All we need to track our subscribers is a shared object. Thus, our subscriptions module exports an empty object literal. We can share this across our other modules.

Doesnʼt get much easier than that!

We will add our subscribers to this object by event type and element ID. We need a subscribe function to let us do that:

The subscribe function adds listeners.
import eventListener from "./event-listener.js"
import subscriptions from "./subscriptions.js"

export default function (type, id, callback) {
  subscriptions[type] ??= {}

  if (!Object.keys(subscriptions[type]).length) {
    document.body.addEventListener(type, eventListener)
  }

  subscriptions[type][id] = callback
}

This example begins by importing the subscriptions empty object literal we discussed above. It also imports an eventListener function that weʼll get to below.

Next, we export as default an anonymous function on line #4. This is our subscribe function. This function takes three parameters. The first is the event type, e.g., “click.” The second parameter is the ID of the element on which this event will be raised. And the final parameter is the callback function we will call when the event is triggered.

The first thing we do is to use the nullish coalescing assignment operator “??=” to set the type property to an empty object literal (line #5). If it doesnʼt already exist, of course.

Next, we check if the type object is empty. If it is, we add an event listener for that type to the body element and exit the conditional. Lines #7 to #9.

Finally, we take the ID passed as the second argument to our function. We use this as a key in the type object, and assign it the callback function as its value.

You can see from the code that we begin by importing our subscriptions module. This is no more than a plain JavaScript object.

Then we create an anonymous function that we export as the default. This is our subscribe function. In it, we ensure that our event type exists as an object inside the subscriptions object. If not, we assign an empty object literal to that key. For example, subscriptions["click"] = {}.

OK, so are we already listening for this event type? If we have no subscribers yet, then we can assume not. So we add an event listener to the body element listening for that type of event. We pass it an eventListener function. Weʼll call this function when the user triggers that event. We discuss the eventListener function below.

Finally, after exiting the conditional, we set a property on the type object. The key is the ID of the element weʼre watching. Our callback function is the value. Example: a “click” event on an element with ID of “my-button.” Like this: subscriptions["click"]["my-button"] = eventListener.

What, then, does this eventListener function do? Here it is:

The eventListener function that calls the callback.
import subscriptions from "./subscriptions.js"

export default function (event) {
  const id = event.target?.id

  id && subscriptions?.[event.type]?.[id]?.(event)
}

This example shows the eventListener function. Line #1 imports the shared subscriptions module.

Then we export an anonymous function as the default. This is our event handler, so it takes one parameter: the event. On line #4 we extract the ID of the element from the event.target. Then, if the id exists, we get the callback function from subscriptions[event.type][id]. Finally, we call it, passing the event.

This is a very simple function. It takes the passed event. Then it extracts the ID of the target element. Finally, it uses the event.type and that ID to get the callback function from the subscriptions. We call the callback function and pass it the event.

Done.

Of course, weʼll also need to be able to unsubscribe:

The unsubscribe function removes subscriptions.
import subscriptions from "./subscriptions.js"

export default function (type, id) {
  delete subscriptions?.[type]?.[id]

  if (!Object.keys(subscriptions[type] || {}).length) {
    delete subscriptions[type]
  }
}

As with the other examples, this one imports the subscriptions object on line #1.

Then we export an anonymous function as default. It takes two parameters, type and id. The type is the event type, e.g., “click”; the id is the ID of the target element.

First thing we do is to use the delete operator (line #4) to delete any callbacks for that ID: delete subscriptions dot type dot id.

Then, on lines #6 t0 #8, we check if the object for that event type is empty. If it is, then we also delete that type: delete subscriptions dot type.

Our unsubscribe function works like the subscribe function but in reverse. Who knew?

We import the subscriptions model. Then we export an anonymous function as default. This is our unsubscribe function.

It first ensures the the type key (our event type) exists in the subscriptions object. Then we use the delete operator to remove the ID key from that object, if it exists. Finally, we check to see if there are any remaining keys in that type object. If not, we delete that object as well.

How it works

Letʼs create a button we can use to trigger an event:

A button to trigger the click event.
<button id="do-it">Do it!</button>

This example shows a simple HTML button with id set to “do-it” and text “Do it!”

Rather than adding our click event listener to the element itself, we can use our PubSub system. We will subscribe to click events on the element with ID “do-it” instead:

Here we subscribe to the click event on element “do-it”.
import subscribe from "./modules/subscribe.js"

subscribe("click", "do-it", () => console.log("Click on do-it element."))

In this example, we import the subscribe function. Then we call it and pass it three arguments:

  1. The event type, e.g., “click”.
  2. The ID of the element: in this instance, “do-it”.
  3. And a callback function, which in this example simply logs “Click on do-it element” to the console.

This gives us the following subscriptions object:

The subscriptions object showing our subscription.
{
  click: {
    "do-it": [eventListener function]
  }
}

This example shows a JavaScript object with a key of click. The value of the click key is another object. This “click” object has a key of do-it, the value of which is our eventListener callback function.

We have also added a click event listener to the body element. This is our eventListener function above. When the user clicks the “do-it” button, this function looks up the callback. Example: subscriptions["click"]["do-it"]. It then calls function, passing it the event object.

Given our above example, this prints out “Click on do-it element.” to the console.

And to unsubscribe:

Unsubscribing is easy.
import unsubscribe from "./modules/unsubscribe.js"

unsubscribe("click", "do-it")

In this example, we import the unsubscribe function. Then we call it with arguments “click” and the element ID, “do-it”.

Wait … what about the pub in PubSub?

Ah ha! Good catch. Where is our publish function?

Hmm. Our simple PubSub module is actually only half an implementation. What we have here is more event delegation. It is the browser that does the publishing by raising events. All we do is to subscribe to those events.

Guess what Part 2 of this essay involves.

But before we go there, there are some problems with this model. Event delegation is efficient and the way to go, but not all events bubble up to the body. Sigh … nothingʼs perfect.

The two that we most want to handle are focus and blur. The good news is that we can use focusin and focusout on the body to do the same thing. But it would be nice if module users could still think focus and blur.

So we can update our functions to map one set of terms to the other. We will need a mapper function. We can extend this later as necessary.

Casting focus and blur to focusin and focusout.
export default function (type) {
  if (type === "blur") {
    return "focusout"
  }

  if (type === "focus") {
    return "focusin"
  }

  return type
}

This example shows the castEvent function exported as a default anonymous function. It takes one parameter: the event type.

It contains two conditionals. The first checks if the event type is “blur”; if it is, then it returns “focusout” instead. The second does the same for “focus”, returning “focusin” instead.

If neither conditional is true, then the function returns the type unaltered.

We can update our subscribe function:

Subscribing while mapping focus and blur.
import castEvent from "./cast-event.js"
import eventListener from "./event-listener.js"
import subscriptions from "./subscriptions.js"

export default function (eventType, id, callback) {
  const type = castEvent(eventType)

  subscriptions[type] ??= {}

  if (!Object.keys(subscriptions[type]).length) {
    document.body.addEventListener(type, eventListener)
  }

  subscriptions[type][id] = callback
}

This example shows the same subscribe function as above, but we have imported our castEvent function. We use this to map our event type argument before continuing on with the function.

And weʼll need to do the same to our unsubscribe function:

Unsubscribing while mapping focus and blur.
import castEvent from "./cast-event.js"
import subscriptions from "./subscriptions.js"

export default function (eventType, id) {
  const type = castEvent(eventType)

  delete subscriptions?.[type]?.[id]

  if (!Object.keys(subscriptions[type] || {}).length) {
    delete subscriptions[type]
  }
}

As with the subscribe function above, we import the castEvent function into our module, then use it to map the event type before proceeding as before.

We will continue this in Part 2. Meanwhile, here is a simple, vanilla JS example.

Open up the browserʼs DevTools console and then click on the “DO IT!” button. Nothing should happen. Now click the “SUBSCRIBE” button. Youʼve now subscribed to clicks on the “DO IT!” button. Click DO IT!. This time you should see a message in the console.

Now click the “UNSUBSCRIBE” button. You have unsubscribed and further clicks on DO IT! will have no effect.

You can also see how the castEvent mapping works. Take a look at the page source.

On lines #96 to #102 you can see how we subscribed to the focus and blur events on the SUBSCRIBE button. Tab to the button (or click on it) to focus it, and then tab away. You should see messages in the console to show that you focused then blurred the button.

Nice, eh?

Links to related pages

Essays about code. Essays about connection. Essays about context. Critiques of coding practices.

Get notified form

Get notified of site updates
Button bar

Carbon emissions for this page

Cleaner than 99% of pages tested
0.016g on first visit; then on return visits 0.009g
QR Code

Scan this code to open this page on another device.