The publish/subscribe model (often shortened to pub/sub) helps you decouple features. A component publishes an event. Every subscriber that subscribed to that event runs its callback. The publisher doesn't know who subscribed. The subscriber doesn't need to know who published. The result: simple, testable code.
What does subscribe mean?
To subscribe is to register a callback for a named event. You can have one subscriber or many subscribers for the same event. When someone publishes that event, each subscriber's callback runs with the provided data.
// Minimal shape we aim for
// bus.subscribe(eventName, callback)
// bus.publish(eventName, ...args)
A tiny, robust Pub/Sub utility
Here's a clean implementation that covers the real-world needs: subscribe, publish, unsubscribe, and subscribeOnce. It's small, fast, and framework-agnostic.
export function createPubSub() {
const subscribersByEvent = Object.create(null);
function subscribe(eventName, callback) {
if (typeof callback !== 'function') {
throw new TypeError('subscriber callback must be a function');
}
if (!Array.isArray(subscribersByEvent[eventName])) {
subscribersByEvent[eventName] = [];
}
subscribersByEvent[eventName].push(callback);
// return unsubscribe handle for convenience
return function unsubscribe() {
const list = subscribersByEvent[eventName];
if (!list) return;
const index = list.indexOf(callback);
if (index !== -1) list.splice(index, 1);
if (list.length === 0) delete subscribersByEvent[eventName];
};
}
function subscribeOnce(eventName, callback) {
const unsubscribe = subscribe(eventName, function wrappedOnce(...args) {
unsubscribe();
callback(...args);
});
return unsubscribe;
}
function publish(eventName, ...args) {
const list = subscribersByEvent[eventName];
if (!list || list.length === 0) return 0;
// copy to prevent mutation during publish from breaking iteration
const currentSubscribers = list.slice();
for (const subscriber of currentSubscribers) {
try {
subscriber(...args);
} catch (error) {
// In production, you might log this error. We keep the loop going
// so one bad subscriber doesn't break others.
}
}
return currentSubscribers.length;
}
function clear(eventName) {
if (eventName) {
delete subscribersByEvent[eventName];
} else {
for (const key in subscribersByEvent) delete subscribersByEvent[key];
}
}
return { subscribe, subscribeOnce, publish, clear };
}
// Example usage
const bus = createPubSub();
const unsubscribeAlert = bus.subscribe('alert', (payload) => {
console.log('Alert subscriber:', payload.message);
});
bus.publish('alert', { message: 'Hello, subscriber!' });
// Stop this subscriber later
unsubscribeAlert();
// Subscribe once
bus.subscribeOnce('ready', () => console.log('Ready fired once'));
bus.publish('ready');
bus.publish('ready'); // second call does nothing
Subscribers in the UI: simple wiring
A common pattern is keeping UI components lean. The form publishes events like form:success
or
form:error
. A toast system subscribes to those events and shows messages. The form doesn't import the
toast. The toast doesn't import the form.
// form.js
import { bus } from './shared-bus.js';
async function submitForm(data) {
try {
await api.save(data);
bus.publish('form:success', { message: 'Saved!' });
} catch (error) {
bus.publish('form:error', { message: 'Please try again.' });
}
}
// toast.js
import { bus } from './shared-bus.js';
bus.subscribe('form:success', ({ message }) => showToast(message, 'success'));
bus.subscribe('form:error', ({ message }) => showToast(message, 'error'));
Unsubscribe and memory safety
Always keep an unsubscribe handle. Remove the subscriber when a component unmounts or a page changes. This prevents dangling subscribers and memory leaks.
const stop = bus.subscribe('tick', () => updateTime());
// later
stop(); // unsubscribe this subscriber
Pattern tips and best practices
- Name events consistently: Use namespaces like
cart:add
,user:login
. - Return unsubscribe: Every subscribe should return a clean unsubscribe function.
- Keep payloads simple: Prefer plain objects. Document required fields.
- Don't overuse pub/sub: Direct function calls are fine when coupling is intentional.
- Handle errors per subscriber: One broken subscriber should not halt others.
- Provide subscribeOnce: Handy for one-off lifecycle moments like
ready
.
Typed channels (optional)
In TypeScript, you can type event names and payloads for safer subscribe and publish calls.
type Events = {
'alert': { message: string };
'ready': void;
};
function createTypedPubSub>() {
const bus = createPubSub();
return bus as {
subscribe(event: K, cb: (payload: E[K]) => void): () => void;
subscribeOnce(event: K, cb: (payload: E[K]) => void): () => void;
publish(event: K, payload: E[K]): number;
clear(event?: keyof E): void;
};
}
const typedBus = createTypedPubSub();
typedBus.subscribe('alert', (p) => console.log(p.message));
typedBus.publish('alert', { message: 'Typed and safe' });
Conclusion
The publish/subscribe model gives you a clean way to connect parts of your app without tight coupling. With a lightweight utility, a simple subscribe API, and careful subscriber management, your code stays flexible and maintainable.