Web components

Make Professional Components with Pub/Sub and Custom Events

How to use broadcasters and listeners for dependency-free code

by Joe Honton
Pub/sub event bus

Publish/subscribe is a classic software design pattern that allows pieces of an application to be decoupled. It uses messages instead of functions, closures, or callbacks. It promotes an architectural style that has flexibility through independence.

I recently applied the pub/sub pattern to the state management of a DOM component. Its implementation was at times confusing, but the final result was a satisfying UI/UX. The extra effort was worth it.

Here's a brief walk-through for anyone attempting to use this pattern within a DOM component.

As background, the component I was working on draws a representation of the Earth on an HTML canvas element. The drawing uses trigonometric functions to place points, lines and polygons onto the surface of a sphere, and to project the 3D sphere onto a 2D canvas.

Night/Day, 2020 Solstice, Dec 22 10:02:06 GMT (87.00W 20.00N)

The component is interactive, allowing operations to be performed using a pointer (mouse, touch or pen), floating dialogs with <input> elements, or with an external API.

The key requirement that led me to choose pub/sub was the need to keep the canvas drawing, the mouse pointer, and the floating dialogs completely synchronized.


Broadcasting and listening

To set the stage, here's a straightforward DOM component, containing just enough complexity to explain the pub/sub concept. It has a source of truth Earth, a floating dialog box containing HTML input elements, and a handler for mouse/touch/pen interactions.

class BlueMarble extends HTMLElement {

constructor() {
super();
this.earth = new Earth(this);
this.dialog = new Dialog(this);
this.pointerHandler = new PointerHandler(this);
}
}

The component is added to the host document's HTML using the custom element tag <blue-marble>, defined with this snippet:

window.customElements.define('blue-marble', BlueMarble);

An important aspect here is that BlueMarble extends HTMLElement which extends EventTarget. Because of this, a pub/sub implementation can be created using the well-known event listeners and dispatchers that every frontend developer is familiar with. No special libraries are needed.

Here's what the publishing method looks like when added to the BlueMarble class:

broadcastMessage(topic, data) {
var customEvent = new CustomEvent(topic, {detail: data});
this.dispatchEvent(customEvent);
}

There doesn't appear to be much going on here, just CustomEvent and dispatchEvent, which are built into the DOM and ready to use. The topic argument is an arbitrary string value chosen by the developer. The data argument is a scalar value or generic object with properties. And this is the BlueMarble component. That's all there is to it.

The other parts of the DOM component use this broadcast capability whenever they intend to make changes to the source of truth. So a floating dialog with text inputs for changing the latitude/longitude would listen for changes to be made, then broadcast those changes for any registered subscribers.

class Dialog {

constructor(blueMarble) {
var elLat = blueMarble.shadowRoot.getElementById('lat');
var elLng = blueMarble.shadowRoot.getElementById('lng');

elLat.addEventListener('change', () => {
blueMarble.broadcastMessage('user/changeLatitude',
elLat.value) });
elLng.addEventListener('change', () => {
blueMarble.broadcastMessage('user/changeLongitude',
elLng.value) });
}
}
Point of reference floating dialog

With the BlueMarble example, dialog boxes aren't the only way to change the latitude/longitude: the pointer handler can also broadcast the corresponding latitude/longitude by responding to clicks on the canvas. Furthermore, BlueMarble's public API has methods for initiating the same latitude/longitude change.

In all three cases (dialogs, pointer handler, API) messages are used instead of direct calls to the Earth source of truth. This decoupling of the source of truth from the change originators is the essence of the pub/sub pattern.

The subscribe method, the other half of pub/sub, is implemented with the familiar addEventListener function. The Earth class listens for changes to latitude/longitude coming from any of the three broadcasters, and saves them to its state:

class Earth {

constructor(blueMarble) {
this.latitude = 0.0;
this.longitude = 0.0;

blueMarble.addEventListener('user/changeLatitude',
(event) => { this.latitude = event.detail });
blueMarble.addEventListener('user/changeLongitude',
(event) => { this.longitude = event.detail });
}
}

For the case just outlined, the component element BlueMarble is considered to be the message broker. It is responsible for shuttling messages between the senders (mouse, touch, floating dialogs, API) and the receiver Earth. When you hear others talk about an "event bus", it is a synonymous term for message broker.

In the working example, Earth isn't a monolithic object, but rather a collection of several objects each with a single responsibility (scaling, rotation, projection, styling, etc.) Pub/sub is an ideal architecture for such cases. The change originators do not send messages directly to any single implementation-specific object. Instead, they broadcast messages unilaterally, and allow the objects themselves to determine which messages to listen for.

This separation of sender and receiver affords flexibility to both the developer and consumers of the component. Listeners are completely optional. This means that messages may be silently ignored when nobody cares. Or conversely, more than one listener may be called upon to act on it, when that makes sense.

Incoming and outgoing events

As it stands, communication is going only one way. The reverse direction must be implemented to propagate changes outward to interested parties so that they may reflect the new source of truth value.

The same broadcaster/listener approach is used, but this time going from the Earth object to the floating dialog box and the canvas drawing routine. The relevant parts of the Earth class become:

blueMarble.addEventListener('user/changeLatitude', (event) => {
var lat = event.detail;
if (validateLatitude(lat)) {
lat = canonicalizeLatitude(lat);
this.latitude = lat;
blueMarble.broadcastMessage('earth/changedLatitude', lat);
}
else
blueMarble.broadcastMessage('earth/invalidLatitude', lat);
});

blueMarble.addEventListener('user/changeLongitude', (event) => {
var lng = event.detail;
if (validateLongitude(lng)) {
lng = canonicalizeLongitude(lng);
this.longitude = lng;
blueMarble.broadcastMessage('earth/changedLongitude', lng);
}
else
blueMarble.broadcastMessage('earth/invalidLongitude', lng);
});

These are the same two listeners from before, but expanded to be more robust:

  • The validateLatitude and validateLongitude functions (not shown) make sure that the incoming values are acceptable, that is, they are not NaN, and they are within the meaningful range (-90 to 90 for latitude, and -180 to 180 for longitude).
  • Invalid values are sent back to the originators via earth/invalidLatitude and earth/invalidLongitude, which the UI should listen for and act upon appropriately.
  • Valid values are canonicalized so that -87 becomes 87.00 W and 20 becomes 20.00 N.
  • The canonicalized values are broadcast using earth/changedLatitude and earth/changedLongitude, which the canvas will receive and redraw accordingly. The floating menu will also receive these — even if it was the originator of the changes — and will update itself with the canonicalized representation.
BlueMarble pub/sub event bus

Careful readers will note that messages going from the subjects to the source of truth are prefixed with user/ and messages going the other way are prefixed with earth/. There is no requirement to name things this way, but it helps keep the direction of flow clear.


Alternate Representations

Incoming change requests may be received in a non-canonical format. This should be accommodated, and the canonicalization should happen at the receiving end (the source of truth). When broadcasting changes from the source of truth, the broadcast should send out the canonicalized version.

To handle conversions between the canonical version and the user representation, pairs of static helper functions are useful. For the working example, we might have helpers like these:

  • toEW for representing -179.8347 as 179.8347 W
  • toDMS for representing -179.8347 as -179° 50' 5"
  • toHMS for representing -179.8347 as -179h 50m 5s
  • fromEW for canonicalizing 179.8347 W as -179.8347
  • fromDMS for canonicalizing -179° 50' 5" as -179.8347
  • fromHMS for canonicalizing -179h 50m 5s as -179.8347

Pub/sub scoping

When using pub/sub within a component, developers should make a conscious decision about which dispatched events should be public and which should be kept private. This is done by setting the CustomEvent's composed flag to true (public) or false (private).

Normally, custom events broadcast by elements within a component do not cross the shadow DOM boundary: they will bubble up to the component's shadow root and go no further. This keeps the component from polluting the outside world. Nevertheless, this behavior can be overridden by creating the custom event with both the bubbles and composed flags set to true:

const customEvent = new CustomEvent('user/changedLatLng', {
bubbles: true,
composed: true,
detail: {
latitude: lat,
longitude: lng
},
});

On the other hand, messages broadcast from the component itself, as opposed to an element within the component, are visible to the host document when they are created with bubbles: true, irrespective of the composed flag's setting. This is because the component itself is in the "light DOM".

Careful message tracing during development will make it clear which broadcasts are making it through the shadow DOM boundary to the host document.


Best practices for DOM pub/sub

In summary, the pub/sub pattern helps remove dependencies between the source of truth and the user interface. A good use case for this is when values are displayed to the user in multiple representations (text and graphics).

Unfortunately pub/sub can become a tangled mess — because the code for publishing and subscribing are almost always in separate modules. The key to success is staying organized. Follow these useful tips:

  • A message event bus should be designated to send and receive messages.
  • Incoming and outgoing messages should be separated, and both should follow a good naming convention.
  • Incoming change requests should be validated before setting the source of truth, and rejected if necessary, before rippling through the system.
  • Only valid, canonicalized values should be reflected back to the user interface from the source of truth.
  • User formatting for alternate representations should happen on the receiving end of reflected messages.

The BlueMarble code shown here has been simplified to clarify the pub/sub concept. It is based on the rwt-orthographic-earth DOM component, which is used on the full.earth website.

Make Professional Components with Pub/Sub and Custom Events — How to use broadcasters and listeners for dependency-free code

🔎