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 {}
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
}
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]
}
}
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
}
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 {}
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
}
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]
}
}
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)
}
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. »")
[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:
- /modules/cast-event.js
- /modules/listeners.js
- /modules/publish.js
- /modules/register.js
- /modules/subscribe.js
- /modules/subscriptions.js
- /modules/unregister.js
- /modules/unsubscribe.js
- index.js
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>
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>
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>
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:
- prevents the default submission so that we donʼt reload the page
- calls the
publish
PubSub function to publish our custom event. We will give it the typeNAME_UPDATED
. And weʼll pass adetails
object with theid
andvalue
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>
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)