DEV Community

Mohsen Fallahnejad
Mohsen Fallahnejad

Posted on

JavaScript Event Bus 🚍 (JS + TypeScript)

Event Bus = simple publish/subscribe hub so modules can talk without direct references.


1) Why use an Event Bus?

  • Decoupling: publishers don't import subscribers.
  • Reusable communication: one signal β†’ many independent reactions.
  • Great for: auth signals, toasts, analytics, cross-feature coordination.

Use a bus sparingly. For large apps, prefer a structured state manager (Redux, Zustand, Vuex) for core state.


2) Minimal, Fast JavaScript Implementation

// eventBus.js
export class EventBus {
  constructor() { this.map = Object.create(null) }

  on(event, cb) {
    (this.map[event] ||= new Set()).add(cb)
    return () => this.off(event, cb)       // unsubscribe helper
  }

  once(event, cb) {
    const off = this.on(event, (p) => { off(); cb(p) })
    return off
  }

  off(event, cb) {
    const set = this.map[event]; if (!set) return
    set.delete(cb); if (set.size === 0) delete this.map[event]
  }

  emit(event, payload) {
    const call = (set) => set && set.forEach(fn => fn(payload))
    // direct
    call(this.map[event])
    // simple wildcard: "user:*" matches "user:login"
    const star = event.split(':')[0] + ':*'
    call(this.map[star])
  }
}

// Usage
export const bus = new EventBus()

const off = bus.on('user:login', (u) => console.log('hello', u.name))
bus.once('user:login', () => console.log('toast shown once'))

bus.emit('user:login', { name: 'Alice' })
// later
off()
Enter fullscreen mode Exit fullscreen mode

Why this version?

  • Set avoids duplicates, once auto-cleans, wildcard channel "domain:*" is lightweight.

3) Strongly-Typed TypeScript Bus (safe contracts)

// typedBus.ts
type Events = {
  'user:login': { name: string }
  'user:logout': void
  'chat:message': { text: string; from: string }
  'user:*': unknown               // wildcard channel (optional)
}

export class TypedBus<E extends Record<string, any>> {
  private map: { [K in keyof E]?: Set<(p: E[K]) => void> } = {};

  on<K extends keyof E>(event: K, cb: (payload: E[K]) => void) {
    (this.map[event] ||= new Set()).add(cb)
    return () => this.off(event, cb)
  }

  once<K extends keyof E>(event: K, cb: (payload: E[K]) => void) {
    const off = this.on(event, (p) => { off(); cb(p) })
    return off
  }

  off<K extends keyof E>(event: K, cb: (payload: E[K]) => void) {
    const set = this.map[event]; if (!set) return
    set.delete(cb); if (set.size === 0) delete this.map[event]
  }

  emit<K extends keyof E>(event: K, payload: E[K]) {
    const call = (set?: Set<(p: any) => void>) => set?.forEach(fn => fn(payload))
    call(this.map[event])
    // naive wildcard match: prefix + ':*'
    const star = (String(event).split(':')[0] + ':*') as keyof E
    call(this.map[star])
  }
}

export const bus = new TypedBus<Events>()
Enter fullscreen mode Exit fullscreen mode

Contract benefits: Autocomplete for event names & payload shapes; compile-time safety.


4) React Usage Patterns

A. Global bus instance (simple apps)

// bus.ts
export const bus = new EventBus()

// Navbar.tsx
useEffect(() => bus.on('user:login', () => setOpen(true)), [])

// LoginForm.tsx
const submit = () => bus.emit('user:login', { name: 'Alice' })
Enter fullscreen mode Exit fullscreen mode

B. Context + scoped bus (feature isolation / testability)

const BusContext = createContext<{
  bus: EventBus
} | null>(null)

export function BusProvider({ children }) {
  const [bus] = useState(() => new EventBus())
  return <BusContext.Provider value={{ bus }}>{children}</BusContext.Provider>
}

export function useBus() {
  const ctx = useContext(BusContext); if (!ctx) throw new Error('No BusProvider')
  return ctx.bus
}
Enter fullscreen mode Exit fullscreen mode

C. Clean up subscriptions

useEffect(() => {
  const off = bus.on('toast:show', setToast)
  return off                     // auto-unsubscribe on unmount
}, [])
Enter fullscreen mode Exit fullscreen mode

5) Node.js: you already have one

import { EventEmitter } from 'node:events'
const bus = new EventEmitter()

bus.on('ping', () => console.log('pong'))
bus.emit('ping')
Enter fullscreen mode Exit fullscreen mode

Use Node's EventEmitter for server code; for browsers and shared libs, a tiny custom bus is fine.


6) Tips & Pitfalls

  • Name events with namespaces: user:login, cart:itemAdded.
  • Provide a registry doc (events & payloads) to avoid mismatches.
  • Avoid global everything: consider scoped buses per feature.
  • Memory leaks: always unsubscribe on unmount; once helps.
  • Prefer state managers for shared state; use the bus for signals.

7) Quick Cheatsheet

Need Suggestion
One-off global signal (toast/auth) Event bus
Complex shared state Redux/Zustand/Context
Typed payloads & autocomplete TypeScript TypedBus
Only one reaction allowed once
Fan-out to many listeners plain emit or wildcard

Originally published on: Bitlyst

Top comments (0)