Tiny Pub/Sub Event Bus
When two parts of your app need to talk without a direct reference (a Zustand store telling the toaster, a service worker pushing to the UI), a tiny pub/sub bus is often cleaner than threading callbacks. This snippet builds a minimal `on/off/emit` bus, adds a `once` shorthand, and shows the typed channels pattern that scales to a real codebase. Drop it in next to your state library.
942 views
18
function createBus() {
const listeners = new Map();
function on(event, fn) {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event).add(fn);
return () => listeners.get(event).delete(fn);
}
function off(event, fn) {
const set = listeners.get(event);
if (set) set.delete(fn);
}
function emit(event, payload) {
const set = listeners.get(event);
if (!set) return;
for (const fn of [...set]) fn(payload);
}
return { on, off, emit };
}
const bus = createBus();
bus.on('user:login', (u) => console.log('hi', u.name));
bus.emit('user:login', { name: 'Ada' });Storing listeners in a Map<event, Set<handler>> gives O(1) add/remove and prevents duplicate registrations of the same function. Returning an unsubscribe function from on is a small ergonomic win that pairs well with React effects (return unsubscribe) and removes the need for callers to keep a reference to the original handler. Iterating over [...set] in emit snapshots the listener list so a handler that calls off mid-dispatch does not skip later listeners. Use this when you need decoupled cross-tree messaging without pulling in a dependency.
function createBus2() {
const listeners = new Map();
function on(event, fn) {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event).add(fn);
return () => listeners.get(event).delete(fn);
}
function emit(event, payload) {
const set = listeners.get(event);
if (set) for (const fn of [...set]) fn(payload);
}
function once(event, fn) {
const off = on(event, (payload) => {
off();
fn(payload);
});
return off;
}
return { on, emit, once };
}
const bus2 = createBus2();
bus2.once('boot:done', () => console.log('seen exactly one boot signal'));
bus2.emit('boot:done', null);
bus2.emit('boot:done', null); // ignoredonce is the right primitive for boot signals, modal-confirm replies, and any 'wait for the next event of type X' pattern. Implementing it as on plus an immediate off() inside the wrapped handler keeps the listener registry clean and fires exactly once even if emit is called many times. Returning the unsubscribe function lets callers cancel a pending one-shot if the surrounding component unmounts before the event fires. Pair this with Promises by writing new Promise((res) => bus.once('boot:done', res)) for await-friendly call sites.
function createTypedBus() {
const listeners = new Map();
return {
on(event, fn) {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event).add(fn);
return () => listeners.get(event).delete(fn);
},
emit(event, payload) {
const set = listeners.get(event);
if (set) for (const fn of [...set]) fn(payload);
},
channel(event) {
return {
emit: (payload) => this.emit(event, payload),
on: (fn) => this.on(event, fn),
};
},
};
}
const bus3 = createTypedBus();
const userLogin = bus3.channel('user:login');
userLogin.on((u) => console.log('typed bus hi', u.name));
userLogin.emit({ name: 'Grace' });Wrapping a single event in a channel object lets callers consume the bus without typing the event name at every site, which is where typos hide bugs. In a TypeScript codebase, the channel<E extends keyof Events> generic narrows the payload to the right shape per event, so the compiler catches userLogin.emit({ wrong: true }). The pattern also makes mocking trivial in tests: pass a fake userLogin channel instead of the whole bus. Keep emit/on on the bus root for legacy code paths and let new code pick channels.
