Share options

Links to related pages

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

Adding simple PubSub, part 2

From event delegation to true pubsub.

Time to read

, 1260 words, 5th 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:

Now our oh-so-complex listeners module.
export default {}

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

Our new 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
}

This example shows our register function that registers listeners to listen for browser events on the body element.

On lines #1 and #2, we import our castEvent function that we created in our previous article. This casts focus to focusin and blur to focusout so we can catch them at the body element. We also import our listeners object that will hold our event listeners for events such as click, pointerover, submit, etc.

Our function (line #4 to #19) takes three parameters and returns void. The paramters are:

  1. The eventType for which to listen, such as click.
  2. The id of the expected target element.
  3. The callback function to call when the eventType event is raised on the target element with ID id. This is passed the event object.

First, we cast the eventType as necessary on line #5.

Then we use nullish coalescing assignment operator (??=) to ensure that the key for this type has an object value on line #7.

Next, line #9 to #16, we check if this object has any listeners registered. If not, then we need to add the event listener for this type to the document body element. This takes the type, e.g., click, and a callback function.

The callback function gets the target id and the event type from the event passed to it, then uses those to find a listener callback stored in listeners dot type dot id. If it finds one, it calls it and passes the event along. This happens inside the event listener and is called on that event.

Now, to finish our register function, we assign the callback passed on line #4 to listeners dot type dot id, where, for example, type might be “click” and id might be “my-element”.

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

The unregister function removes listeners.
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]
	}
}

In this example, as in the previous one above, we begin by importing the castEvent function (line #1) and the listeners shared object (line #2).

Then we create our unregister function on lines #4 to #12. This function takes two parameters: the eventType and the target id, as did the register function.

We cast the event type using castEvent on line #5. So focus becomes focusin and blur becomes focusout. We cast these in our register function to allow us to catch these events after bubbling up to the body element. blur and focus do not bubble up.

On line #7, we delete the key-value pair for that ID in the listeners object for that type. Then on lines #9 to #11, we check if that type object is empty and if so, delete it as well.

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:

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.

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:

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

As before, this example is as simple as it gets: export default empty object literal.

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.

Big changes in 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
}

This JavaScript example imports our subscriptions object on line #1. Lines #3 to #19 export the anonymous subscribe function as default.

Our function takes a topic and a callback function. On line #4 we use the nullish coalescing assignment operator to assign an empty object to this topic if one does not already exist.

Then on lines #6 to #12, we check if this topicʼs object is empty. If it is, then we need to add an event listener for the topic. The callback we pass to the listener loops through all the callback functions for this topic. It calls each in turn and passes it the event.

Then we use the crypto moduleʼs randomUUID function to generate a UUID as our token. We assign the callback to that token as key in our subscriptions object for this topic, then we return the token to be used to unsubscribe.

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 (line #4).

If this is the first subscription of this type, then we add a listener for this event type (lines #6 to #12). 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 (line #18).

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

Our simple unsubscribe function.
import subscriptions from "./subscriptions.js"

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

	if (Object.keys(subscriptions[topic]) < 1) {
		delete subscriptions[topic]
	}
}

This JavaScript example of the unsubscribe function begins by importing the subscriptions object. Then, on lines #3 to #9, it exports as default an anonymous function that takes a topic and a token.

Inside the function it uses the delete operator to delete the key-value pair for this topic and token. Then, if the object for this topic has no remaining callbacks, it deletes the object as well.

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:

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

	document.body.dispatchEvent(customEvent)
}

Our example publish function is quite simple. We export an anonymous function as default. It takes a topic and a detail object.

Inside the function, we create a new CustomEvent object, passing it the topic and an options object. The options object sets bubbles to false, and includes our details object.

Then, at the bottom of the function, we call dispatchEvent on the body and pass it our CustomEvent object. Itʼs that simple.

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:

Adding the pubsub system to globalThis.
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. »")

In this example of our index.js module, we begin by importing our modules:

  • listeners
  • publish
  • register
  • subscribe
  • subscriptions
  • unregister
  • unsubscribe

Then we use the nullish coalescing assignment operator to assign an empty object literal to globalThis._xx. Finally, we use Object.assign to add the above modules to this object. This makes them available globally behind the namespace “_xx”.

We end the file by logging “PubSub and event delegation enabled” to the console.

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:

Individually-editable fields.
<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>

This simple HTML example shows three nearly identical forms. Each contains a label, an input element, and a button. The first form is for the name, the second for an email address, and the third for a phone number.

We will use these to trigger custom events on submit and publish them to our PubSub system.

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

Temporary elements for displaying published events.
<pre id="name-output"></pre>
<pre id="email-output"></pre>
<pre id="phone-output"></pre>

This example shows three pre elements. These are for demonstration only. We will post our custom events here, stringified.

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

Importing the pubsub module and registering submit events.
<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>

In this example, we show two script elements. The first has src set to “./index.js” and type set to “module”. The second contains our script, and has type “module” as well.

In our second script element, we add an event listener to globalThis using the addEventListener method. We pass it the “DOMContentLoaded” event and an anonymous arrow function as the callback (listener).

In the callback, we call register from our globalThis._xx module and pass it three arguments:

  1. The event we are registering, in this instance, “submit”
  2. The id of the element that will raise this event, here “name-editor”
  3. A callback function to be called on submission of the form

In the callback function, we first call event.preventDefault() to stop submission of the form. We will publish an event instead.

Then we call the publish function from the globalThis._xx object, passing it two arguments. The first is the topic, here “NAME_UPDATED”. The second is our detail object. Here we give it two key-value pairs: the id of the input and the value of the form input.

Not shown here, we do the same for the email and phone mini forms.

On line #1, we import our PubSub system as a module. Then, on lines #3 to #20, we add a DOMContentLoaded event listener to run our registration code after the DOM loads.

Lines #4 to #15 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:

  • prevents the default submission (line # 8) so we donʼt reload the page
  • 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:

Subscribing to our new custom events.
<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>

In this example, we add subscribers to our page. These will listen for our three custom events. Those would be the events published by the three mini forms upon submit.

We use the subscribe function in the globalThis._xx object. We call it with the topic, e.g., “NAME_UPDATED”, and a callback function. In the callback function we use querySelector to snag the appropriate pre element. Then we create a text node with the stringified event type and details object and append it to the pre element.

Not shown, we do the same for the “EMAIL_UPDATED” and “PHONE_UPDATED” custom events.

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.

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.017g on first visit; then on return visits 0.010g
QR Code

Scan this code to open this page on another device.