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


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.

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) });
}
}

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
andvalidateLongitude
functions (not shown) make sure that the incoming values are acceptable, that is, they are notNaN
, 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
andearth/invalidLongitude
, which the UI should listen for and act upon appropriately. - Valid values are canonicalized so that
-87
becomes87.00 W
and20
becomes20.00 N
. - The canonicalized values are broadcast using
earth/changedLatitude
andearth/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.

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
as179.8347 W
toDMS
for representing-179.8347
as-179° 50' 5"
toHMS
for representing-179.8347
as-179h 50m 5s
fromEW
for canonicalizing179.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.