How to implement pub/sub pattern in JavaScript
The publish/subscribe pattern decouples components by allowing them to communicate through an event bus without knowing about each other. As the creator of CoreUI with over 25 years of JavaScript experience since 2000, I’ve used pub/sub for cross-component communication, plugin systems, and loosely coupled module architectures. The standard implementation stores subscriber functions in a map keyed by event name and calls them when events are published. This eliminates direct dependencies between modules.
Implement a basic pub/sub event emitter.
class EventBus {
constructor() {
this.subscribers = {}
}
subscribe(event, callback) {
if (!this.subscribers[event]) {
this.subscribers[event] = []
}
this.subscribers[event].push(callback)
return () => this.unsubscribe(event, callback)
}
unsubscribe(event, callback) {
if (this.subscribers[event]) {
this.subscribers[event] = this.subscribers[event].filter(cb => cb !== callback)
}
}
publish(event, data) {
if (this.subscribers[event]) {
this.subscribers[event].forEach(callback => callback(data))
}
}
}
const bus = new EventBus()
const unsubscribe = bus.subscribe('user:login', (user) => {
console.log('User logged in:', user.name)
})
bus.publish('user:login', { name: 'Alice', id: 1 })
unsubscribe()
subscribe adds a callback and returns an unsubscribe function. publish calls all callbacks for the event. Returning the cleanup function enables easy removal without storing the reference externally.
Wildcard and Namespaced Events
Support event namespaces for organized subscriptions.
class EventBus {
constructor() {
this.subscribers = new Map()
}
on(event, callback) {
if (!this.subscribers.has(event)) {
this.subscribers.set(event, new Set())
}
this.subscribers.get(event).add(callback)
return () => this.off(event, callback)
}
off(event, callback) {
this.subscribers.get(event)?.delete(callback)
}
emit(event, data) {
this.subscribers.get(event)?.forEach(cb => cb(data))
const namespace = event.split(':')[0]
if (namespace !== event) {
this.subscribers.get(`${namespace}:*`)?.forEach(cb => cb(data, event))
}
}
}
const bus = new EventBus()
bus.on('cart:*', (data, event) => {
console.log(`Cart event [${event}]:`, data)
})
bus.emit('cart:add', { productId: 1 })
bus.emit('cart:remove', { productId: 1 })
bus.emit('cart:clear', {})
Using a Map and Set ensures unique subscribers and faster lookups. Namespace wildcards like cart:* catch all events in a domain. This is useful for logging or analytics.
Once Subscriptions
Subscribe to an event that fires only one time.
class EventBus {
constructor() {
this.subscribers = {}
}
on(event, callback) {
if (!this.subscribers[event]) this.subscribers[event] = []
this.subscribers[event].push(callback)
return () => this.off(event, callback)
}
once(event, callback) {
const wrapper = (data) => {
callback(data)
this.off(event, wrapper)
}
return this.on(event, wrapper)
}
off(event, callback) {
if (this.subscribers[event]) {
this.subscribers[event] = this.subscribers[event].filter(cb => cb !== callback)
}
}
emit(event, data) {
this.subscribers[event]?.forEach(cb => cb(data))
}
}
const bus = new EventBus()
bus.once('app:ready', () => {
console.log('App initialized - this fires only once')
})
bus.emit('app:ready')
bus.emit('app:ready') // Second emit does nothing
once wraps the callback to self-unsubscribe after first call. This is useful for one-time initialization events. The wrapper is stored internally so cleanup works correctly.
Using as Global Singleton
Share a single bus instance across modules.
// eventBus.js
class EventBus {
constructor() {
this.subscribers = {}
}
on(event, callback) {
if (!this.subscribers[event]) this.subscribers[event] = []
this.subscribers[event].push(callback)
return () => this.off(event, callback)
}
off(event, callback) {
if (this.subscribers[event]) {
this.subscribers[event] = this.subscribers[event].filter(cb => cb !== callback)
}
}
emit(event, data) {
this.subscribers[event]?.forEach(cb => cb(data))
}
}
export default new EventBus()
// moduleA.js
import bus from './eventBus.js'
bus.emit('data:loaded', { count: 42 })
// moduleB.js
import bus from './eventBus.js'
bus.on('data:loaded', ({ count }) => {
console.log('Received count:', count)
})
Exporting a single instance creates a natural singleton via ES6 modules. Both modules import the same bus object. This is the simplest pattern for applications that don’t use a framework’s state management.
Best Practice Note
This is the same pub/sub pattern we use in CoreUI for cross-component communication. Always unsubscribe in cleanup functions - leaked subscriptions cause memory leaks and unexpected behavior. Use namespaced event names like module:action to avoid collisions. Document all event names and their payloads as they become an implicit API contract. For complex applications with React, Vue, or Angular, prefer their built-in state management solutions (Context, Pinia, NgRx) which are better tooled. Use pub/sub for lightweight communication or when integrating non-framework code.



