DEV Community

Cover image for Adding simple PubSub, part 2
Charles F. Munat for Craft Code

Posted on • Updated on • Originally published at craft-code.dev

Adding simple PubSub, part 2

From event delegation to true pubsub.

Less than 5 minutes, 1178 words, 4th grade

In our previous article we built a simple event delegation system. Our intent was to develop that into a full PubSub system. We can use this PubSub system to create an event-driven architecture.

It will be better if we split the event delegation feature from the PubSub part so we are clear what weʼre doing. We can begin by creating a listeners object to replace our subscriptions object. Weʼll store our listeners here:

export default {}
Enter fullscreen mode Exit fullscreen mode

We will also rename our subscribe function to register. We will move the event listener callback into our register function:

import castEvent from "./cast-event.js"
import listeners from "./listeners.js"

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

  listeners[type] ??= {}

  if (! Object.keys(listeners[type]).length) {
    document.body.addEventListener(type, function (event) {
      const id = event.target?.id
      const type = event.type

      event.target && listeners?.[type]?.[id]?.(event)
    })
  }

  listeners[type][id] = callback
}
Enter fullscreen mode Exit fullscreen mode

It should be obvious what this does (see our previous essay). Now letʼs change our unsubscribe function to an unregister function:

import castEvent from "./cast-event.js"
import listeners from "./listeners.js"

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

  delete listeners?.[type]?.[id]

  if (! Object.keys(listeners[type] || {}).length) {
    delete listeners[type]
  }
}
Enter fullscreen mode Exit fullscreen mode

Again, this works exactly the same as our previous unsubscribe function. Now we have moved our event delegation system into itʼs own module. Now to our PubSub system.

Note that after we unregister the last listener, we clean up after ourselves.

As a reminder, here is our castEvent function code:

export default function (type) {
  if (type === "blur") {
    return "focusout"
  }

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

  return type
}
Enter fullscreen mode Exit fullscreen mode

Adding the PubSub system

We copied the code above into new files. This left our previous pubsub files. So, we already have our basic PubSub module. But we will need to update them a bit.

The subscriptions shared object remains as before:

export default {}
Enter fullscreen mode Exit fullscreen mode

Our unsubscribe function is also as before. But as we are no longer dealing with browser events, we donʼt need the castEvent function anymore. Those browser events go to our event delegator system above, not to PubSub.

We have no ID, but weʼll want a way to unsubscribe, so we generate a UUID and return that from our subscribe function.

import subscriptions from "./subscriptions.js"

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

  if (Object.keys(subscriptions[topic]) < 1) {
    document.body.addEventListener(
      topic,
      (event) => {
        for (
          const cb of Object.values(subscriptions[topic])
        ) {
          cb(event)
        }
      },
      true
    )
  }

  const token = crypto.randomUUID()

  subscriptions[topic][token] = callback

  return token
}
Enter fullscreen mode Exit fullscreen mode

This is pretty self-explanatory, I hope. Our subscribe function takes two parameters:

  • the event type to listen for, e.g., EMAIL_UPDATED
  • the callback function to call when some component publishes an event of this type

We will decide which events to publish and to subscribe to. The nice thing about a PubSub system is that we can create any event we like. We are not limited to browser events.

First, we ensure that there is an object associated with this subscription type.

If this is the first subscription of this type, then we add a listener for this event type. Our callback loops through all the individual callbacks stored in the subscriptions object. It calls each in turn, passing the current event object.

Then we generate a random UUID as our token. We use this to assign the passed-in callback to this topic and token (line #16). Finally, we return the token for use by the unsubscribe function.

What about our unsubscribe function? It is simple as can be:

import subscriptions from "./subscriptions.js"

export default function (topic, token) {
  delete subscriptions?.[topic]?.[token]

  if (Object.keys(subscriptions[topic]) < 1) {
    delete subscriptions[topic]
  }
}
Enter fullscreen mode Exit fullscreen mode

We take as parameters the topic and the token. Then we delete the callback for that topic/token in the subscriptions object.

Finally, if there are no remaining subscribers for that topic, we delete the topic.

Now we need a publish function to allow us to publish to custom events. Here it is:

export default function (topic, detail = {}) {
  const customEvent = new CustomEvent(
    topic,
    {
      bubbles: false,
      detail,
    }
  )

  document.body.dispatchEvent(customEvent)
}
Enter fullscreen mode Exit fullscreen mode

We pass the topic, which is our custom event such as EMAIL_UPDATED. And we pass a detail object. This is the data that our custom event will carry. It could be anything. See our examples below.

On lines #2 to #8, we create a CustomEvent. We assign it our topic as the event type. This is what our subscribers are listening for. We also pass the detail object and set event bubbling to false.

Then on line #10 we call dispatchEvent on the document.body element to dispatch our custom event. By dispatching it and listening for it on the body element, we do not need to bubble it.

Our PubSub system in action

We add the PubSub system to our window (globalThis) object. We add a single object representing our namespace, _xx. Then we add our various modules to that:

import listeners from "./modules/listeners.js"
import publish from "./modules/publish.js"
import register from "./modules/register.js"
import subscribe from "./modules/subscribe.js"
import subscriptions from "./modules/subscriptions.js"
import unregister from "./modules/unregister.js"
import unsubscribe from "./modules/unsubscribe.js"

globalThis._xx ??= {}

Object.assign(globalThis._xx, {
  listeners,
  publish,
  register,
  subscribe,
  subscriptions,
  unregister,
  unsubscribe,
})

console.info("« PubSub and event delegation enabled. »")
Enter fullscreen mode Exit fullscreen mode

[There must be a better way to do this. Ideas?]

Weʼve set up an example of the PubSub system in use. You can see the various modules there:

Now letʼs set up a simple test page. Imagine that we have a set of individually-editable fields, such as name, email, and phone:

<form id="name-editor">
  <label for="name">Name</label>
  <input id="name" name="name" type="text" />
  <button type="submit">Update</button>
</form>
<form id="email-editor">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" />
  <button type="submit">Update</button>
</form>
<form id="phone-editor">
  <label for="phone">Phone</label>
  <input id="phone" name="phone" type="tel" />
  <button type="submit">Update</button>
</form>
Enter fullscreen mode Exit fullscreen mode

And we will include some temporary elements to permit us to display our events:

<pre id="name-output"></pre>
<pre id="email-output"></pre>
<pre id="phone-output"></pre>
Enter fullscreen mode Exit fullscreen mode

Now in our script we first import the PubSub module and then register our event listeners for submit:

<script src="./index.js" type="module"></script>
<script type="module">
  globalThis.addEventListener(
    "DOMContentLoaded",
    async () => {
      globalThis._xx?.register(
        "submit",
        "name-editor",
        (event) => {
          event.preventDefault()

          globalThis._xx?.publish("NAME_UPDATED", {
            id: event.target.elements.name.id,
            name: event.target.elements.name.value,
          }
        )
      },
    )

    // register submit listeners for email and phone, too
  })
</script>
Enter fullscreen mode Exit fullscreen mode

First, we import our PubSub system as a module. Then we add a DOMContentLoaded event listener to run our registration code after the DOM loads.

Next we use the register function of our event delegation system to register a submit handler. This listens for a submit event on the form with the passed ID of “name-editor”. On submit, it calls the passed callback, which:

  1. prevents the default submission so that we donʼt reload the page
  2. calls the publish PubSub function to publish our custom event. We will give it the type NAME_UPDATED. And weʼll pass a details object with the id and value of the input.

We do the same thing for the email and phone fields. Now, when we submit any of these mini-forms, PubSub will create a custom event and publish it to our PubSub system.

We can now subscribe to these events anywhere in our app. And when a component publishes that event, then we can do nothing, one thing, or many things.

Our forms donʼt know who is listening to their events. Our subscribers donʼt know (or care) who is raising these events or why. We have decoupled our code. The PubSub system acts as an event bus to pass around these events. And we can make up any events we need.

Now, to show how it works, we will add subscribers. These will post the stringified events to the pre elements we added above:

<script type="module">
  globalThis.addEventListener(
    "DOMContentLoaded",
    async () => {
      // register event listeners that publish custom events

      const first = globalThis._xx?.subscribe(
        "NAME_UPDATED",
        (event) => {
          const pre = document.querySelector("pre#name-output")

          pre.appendChild( 
            document.createTextNode(
              JSON.stringify({
                type: event.type,
                detail: event.detail,
              }, null, 2) + "\n\n"
            )
          )
        },
      )

      // subscribe to EMAIL_UPDATED and
      // PHONE_UPDATED events as well
  })
</script>
Enter fullscreen mode Exit fullscreen mode

Here we call the subscribe function of our PubSub module. We pass it the topic to which we wish to subscribe. And we pass a callback function to call when a component raises that topic.

In our example callback function, we grab the pre element for that event. Then we stringify the event detail and append it as a child to that element.

You can try this yourself on our example test page.

Of course, this is a simple example. We can do much more with this. For example, we can include much more detail about the event. We can also have more generic or more specific events. We could also:

  • Allow handlers to delete themselves after a specific number of calls (e.g., once)
  • Broadcast an event to all topics
  • Use BroadcastChannel to pass events between browser tabs or windows
  • Use websockets (or similar) to pass events between different devices

In part 3 of this series, weʼll extend the system a bit. Stay tuned.

Top comments (0)