Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
ReactDesign PatternsObserver PatternPub-SubEvent Bus

Pub-Sub vs Observer Patterns in React

Compare Pub-Sub and Observer in React — Subject vs Event Bus, when to use each, useEventBus hook, BroadcastChannel cross-tab sync, Context comparison, and toast/analytics use cases.

Jul 5, 202615 min read

Introduction

Pub-Sub vs Observer — two ways to notify multiple listeners when something happens. The Observer pattern couples a Subject directly to its observers (one-to-many, tighter coupling). The Pub-Sub pattern routes messages through an Event Bus broker so publishers and subscribers stay decoupled (many-to-many, topic-based). This guide explains both, compares them in React, and shows when to pick each.

This guide covers:

  1. Observer pattern — Subject, observers, one-to-many, tight coupling
  2. Pub-Sub pattern — publisher, subscriber, message broker, topic-based decoupling
  3. Pattern comparison — when each fits in React
  4. Event Bus implementation — subscribe, publish, typed events
  5. useEventBus hook — lifecycle cleanup, memory-leak prevention
  6. Cross-tab broadcasting — BroadcastChannel API, loop prevention
  7. Pub-Sub vs Context — tree-bound vs disjoint communication
  8. Use cases — toasts, analytics, micro-frontends
  9. Best practices & pitfalls — hidden global state, event explosion, circular chains

Note

Observer and Pub-Sub are communication patterns, not state managers. Use them for fire-and-forget side effects — toasts, analytics, logging — not as a replacement for Context or TanStack Query when components need to read shared application state.

Quick index

#Section
1Observer design pattern
2Pub-Sub design pattern
3Pub-Sub vs Observer in React
4Event Bus implementation
5useEventBus custom hook
6Cross-tab broadcasting
7Pub-Sub vs React Context
8Primary use cases
9Best practices & pitfalls

1. Observer design pattern

The Observer pattern defines a Subject that maintains a list of Observers and notifies them when its state changes.

plaintext
Subject (Observable)
  ├── Observer A  ← notified on change
  ├── Observer B  ← notified on change
  └── Observer C  ← notified on change
CharacteristicMeaning
Subject and observersSubject knows its observers directly
One-to-many relationshipOne subject → many observers
Subject tracks dependenciesSubject registers/unregisters observers dynamically
Tightly coupled architectureObservers typically reference or are referenced by the subject
Notification with payloadSubject passes changed data when notifying

Classic Subject implementation

tsx
type Listener<T> = (payload: T) => void
 
class Subject<T> {
  private observers = new Set<Listener<T>>()
 
  subscribe(observer: Listener<T>) {
    this.observers.add(observer)
    return () => this.observers.delete(observer)
  }
 
  notify(payload: T) {
    this.observers.forEach((observer) => observer(payload))
  }
}
 
// Usage — cart subject notifies badge and analytics directly
const cartSubject = new Subject<{ productId: string; qty: number }>()
 
cartSubject.subscribe(({ productId }) => {
  console.log('Analytics: add_to_cart', productId)
})
 
cartSubject.subscribe(({ qty }) => {
  console.log('Badge update:', qty)
})
 
cartSubject.notify({ productId: 'sku-1', qty: 2 })

Note

In React, the Observer pattern often appears as callback props (onChange, onSelect) or useEffect subscriptions to external stores — the parent is the Subject, children are Observers.

Warning

Direct Subject–Observer coupling makes unit testing and swapping implementations harder. When observers multiply across unrelated features, graduate to Pub-Sub with an Event Bus.

Back to index


2. Pub-Sub design pattern

Publish-Subscribe introduces a Message Broker (Event Bus) between senders and receivers. Publishers emit to topics; subscribers listen to topics — neither knows about the other.

plaintext
Publisher A ──┐
Publisher B ──┼──► Event Bus (topics) ──┬──► Subscriber 1
Publisher C ──┘                         ├──► Subscriber 2
                                        └──► Subscriber 3
CharacteristicMeaning
Publisher, subscriber, brokerThree roles — communication via the bus
Many-to-many relationshipAny publisher can reach any subscriber on a topic
Decoupled entities (topic-based)Components communicate by event name, not import
Asynchronous event communicationHandlers can defer work; publishers do not await
Event Bus as middlemanCentral registry maps event names → handler sets
tsx
// Publisher — no import of ToastHost
appEvents.publish('toast:show', { message: 'Saved!', variant: 'success' })
 
// Subscriber — no import of SaveButton
appEvents.subscribe('toast:show', ({ message }) => showToast(message))

Tip

Name events with a namespace prefix — cart:added, auth:logout, toast:show — to avoid collisions and make DevTools logging grep-friendly.

Back to index


3. Pub-Sub vs Observer in React

Both patterns solve cross-component signaling; they differ in coupling and composition.

DimensionObserverPub-Sub
CouplingSubject knows observers (tighter)Publisher/subscriber decoupled via broker
CompositionOften parent → child callback propsAny component, any tree depth, via Event Bus
Component isolationWeaker — direct referencesStronger — topic-only contract
Best forWidget internals, 1–3 known listenersApp-wide side effects, micro-frontends
Cross-tree commsAwkward without lifting stateNatural — no props or Context required

Observer in React — callback prop

tsx
function SearchInput({ onQueryChange }: { onQueryChange: (q: string) => void }) {
  return <input onChange={(e) => onQueryChange(e.target.value)} />
}
 
function SearchResults({ query }: { query: string }) {
  return <ResultsList query={query} />
}
 
// Parent couples both — classic Observer via lifted state + callbacks
function SearchPage() {
  const [query, setQuery] = useState('')
  return (
    <>
      <SearchInput onQueryChange={setQuery} />
      <SearchResults query={query} />
    </>
  )
}

Pub-Sub in React — no shared parent wiring

tsx
// SaveButton (deep in settings tree)
function SaveButton() {
  return (
    <button onClick={() => appEvents.publish('toast:show', { message: 'Saved!', variant: 'success' })}>Save</button>
  )
}
 
// ToastHost (in root layout — unrelated tree branch)
function ToastHost() {
  useEventBus(appEvents, 'toast:show', ({ message, variant }) => {
    toast[variant](message)
  })
  return null
}

Interview Answer

"Observer vs Pub-Sub in React?"

Observer fits when a component owns a small, known set of dependents — form field → validation message, slider → chart. Pub-Sub fits when publishers and subscribers live in disjoint branches and should not import each other — global toasts, analytics, cross-micro-frontend signals. Prefer Context when subscribers need to render from shared state, not just react to events.

Back to index


4. Event Bus implementation

A typed Event Bus maps event names to handler sets with subscribe (returns unsubscribe) and publish (iterates handlers with payload).

tsx
type Listener<T> = (payload: T) => void
 
export class EventBus<T extends Record<string, unknown>> {
  private listeners: Partial<{ [K in keyof T]: Set<Listener<T[K]>> }> = {}
 
  subscribe<K extends keyof T>(event: K, handler: Listener<T[K]>) {
    if (!this.listeners[event]) {
      this.listeners[event] = new Set()
    }
    this.listeners[event]!.add(handler as Listener<T[keyof T]>)
 
    // Returns unsubscribe — critical for React cleanup
    return () => {
      this.listeners[event]?.delete(handler as Listener<T[keyof T]>)
    }
  }
 
  publish<K extends keyof T>(event: K, payload: T[K]) {
    this.listeners[event]?.forEach((handler) => {
      handler(payload as T[K])
    })
  }
 
  clear(event?: keyof T) {
    if (event) {
      this.listeners[event]?.clear()
    } else {
      this.listeners = {}
    }
  }
}

Typed app events

tsx
type AppEvents = {
  'cart:added': { productId: string; qty: number }
  'toast:show': { message: string; variant: 'success' | 'error' | 'info' }
  'analytics:track': { event: string; properties?: Record<string, unknown> }
  'auth:logout': { reason: string }
}
 
export const appEvents = new EventBus<AppEvents>()

Publisher and subscriber

tsx
// Publisher — product page
function AddToCartButton({ productId }: { productId: string }) {
  function handleClick() {
    addToCartApi(productId)
    appEvents.publish('cart:added', { productId, qty: 1 })
    appEvents.publish('analytics:track', { event: 'add_to_cart', properties: { productId } })
  }
 
  return (
    <button type="button" onClick={handleClick}>
      Add to cart
    </button>
  )
}
 
// Subscriber — cart badge (different branch)
function CartBadge() {
  const [count, setCount] = useState(0)
 
  useEffect(() => {
    return appEvents.subscribe('cart:added', () => setCount((c) => c + 1))
  }, [])
 
  return <span className="badge">{count}</span>
}

Performance

Handlers run synchronously in the same tick as publish. Keep subscribers lightweight — defer heavy work with queueMicrotask, requestIdleCallback, or batch analytics sends.

Note

The listeners map (event name → Set of handlers) allows multiple subscribers per topic and O(1) unsubscribe when the returned cleanup function runs.

Back to index


5. useEventBus custom hook

Wrap subscription in a hook so React automatically unsubscribes on unmount — preventing memory leaks.

tsx
'use client'
 
import { useEffect } from 'react'
import type { EventBus } from './EventBus'
 
export function useEventBus<T extends Record<string, unknown>, K extends keyof T>(
  bus: EventBus<T>,
  event: K,
  handler: (payload: T[K]) => void,
) {
  useEffect(() => {
    return bus.subscribe(event, handler)
  }, [bus, event, handler])
}

Stable handler with useCallback

tsx
'use client'
 
import { useCallback } from 'react'
import { toast } from 'sonner'
 
function ToastHost() {
  const handleToast = useCallback(({ message, variant }: AppEvents['toast:show']) => {
    toast[variant](message)
  }, [])
 
  useEventBus(appEvents, 'toast:show', handleToast)
 
  return null
}

Mount once in root layout

tsx
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <ToastHost />
        <AnalyticsListener />
      </body>
    </html>
  )
}

Warning

Passing an inline arrow function to useEventBus without useCallback re-subscribes every render — use a stable handler or accept the dependency churn intentionally for one-off debug listeners.

Tip

For multiple events in one component, call useEventBus once per event or write useEventBusMultiple(bus, { 'toast:show': fn, 'auth:logout': fn2 }) — keep each subscription's cleanup independent.

Back to index


6. Cross-tab broadcasting

Extend Pub-Sub across browser tabs with the BroadcastChannel Web API — sync cart, auth logout, or theme changes between open instances.

tsx
type TabMessage =
  | { type: 'cart:sync'; payload: { count: number } }
  | { type: 'auth:logout'; payload: { reason: string } }
  | { type: 'theme:change'; payload: { theme: 'light' | 'dark' } }
 
type BroadcastConfig = {
  channelName: string
  ignoreSelf?: boolean
}
 
export function createCrossTabBus(config: BroadcastConfig) {
  const channel = new BroadcastChannel(config.channelName)
 
  const localBus = new EventBus<{
    'tab:message': TabMessage
  }>()
 
  channel.onmessage = (event: MessageEvent<TabMessage>) => {
    // Loop prevention — ignore messages this tab originated
    if (config.ignoreSelf && event.data && (event.data as TabMessage & { _origin?: string })._origin === tabId) {
      return
    }
    localBus.publish('tab:message', event.data)
  }
 
  const tabId = crypto.randomUUID()
 
  function broadcast(message: TabMessage) {
    channel.postMessage({ ...message, _origin: tabId })
    localBus.publish('tab:message', message)
  }
 
  function subscribe(handler: (message: TabMessage) => void) {
    return localBus.subscribe('tab:message', handler)
  }
 
  function close() {
    channel.close()
  }
 
  return { broadcast, subscribe, close }
}
 
export const tabBus = createCrossTabBus({ channelName: 'my-app', ignoreSelf: false })

Global listener for app-wide sync

tsx
'use client'
 
import { useEffect } from 'react'
 
function CrossTabSyncHost() {
  useEffect(() => {
    return tabBus.subscribe((message) => {
      switch (message.type) {
        case 'auth:logout':
          signOutLocal(message.payload.reason)
          break
        case 'cart:sync':
          syncCartCount(message.payload.count)
          break
        case 'theme:change':
          applyTheme(message.payload.theme)
          break
      }
    })
  }, [])
 
  return null
}
 
// Tab A — user logs out
function LogoutButton() {
  return (
    <button
      onClick={() => {
        signOutApi()
        tabBus.broadcast({ type: 'auth:logout', payload: { reason: 'user_initiated' } })
      }}>
      Log out
    </button>
  )
}

Note

BroadcastChannel is same-origin only and not supported in very old browsers. Feature-detect with typeof BroadcastChannel !== 'undefined' and fall back to localStorage events or server push for cross-tab sync.

Warning

Without loop prevention, Tab A broadcasts → Tab B receives → Tab B re-broadcasts → infinite loop. Use _origin tags, ignoreSelf config, or idempotent handlers that do not re-emit on incoming messages.

Back to index


7. Pub-Sub vs React Context

Context and Pub-Sub both share data across components — but they solve different problems.

DimensionReact ContextPub-Sub / Event Bus
Tree bindingTied to React component hierarchyWorks across disjoint trees
Data modelCurrent value all consumers can readEphemeral events — no built-in "current state"
Re-rendersAll context consumers re-render on changeSubscribers run handlers — no automatic UI
FrameworkReact-specificFramework-agnostic utility
Independent appsCannot cross React roots easilyMicro-frontends share a bus module
tsx
// Context — subscribers RENDER from shared value
const ThemeContext = createContext<'light' | 'dark'>('light')
 
function ThemedButton() {
  const theme = useContext(ThemeContext) // re-renders when theme changes
  return <button className={theme}>Click</button>
}
 
// Pub-Sub — subscribers REACT to events (side effects)
function AnalyticsTracker() {
  useEventBus(appEvents, 'cart:added', ({ productId }) => {
    track('add_to_cart', { productId }) // no re-render required
  })
  return null
}

Tip

Use Context when multiple components must display the same state (user, theme, locale). Use Pub-Sub when one component signals and others perform side effects (toast, log, analytics) without needing to re-render.

Note

Pub-Sub is framework agnostic — the same EventBus class works in vanilla JS, Node scripts, or a shared npm package consumed by multiple micro-frontends. Context cannot leave the React tree.

Back to index


8. Primary use cases

Use casePatternExample event
Global toast notificationsPub-Subtoast:show → ToastHost in layout
Analytics and loggingPub-Subanalytics:track → Segment listener
Micro-frontend communicationPub-SubShared appEvents npm package
Modal / command palettePub-Submodal:open, command:run
Form field coordinationObserverParent callbacks to sibling validators
External store subscriptionObserverstore.subscribe() in useEffect

Toast notification system

tsx
'use client'
 
function SaveProfileButton() {
  async function handleSave() {
    try {
      await saveProfile()
      appEvents.publish('toast:show', { message: 'Profile saved', variant: 'success' })
    } catch {
      appEvents.publish('toast:show', { message: 'Save failed', variant: 'error' })
    }
  }
 
  return (
    <button type="button" onClick={handleSave}>
      Save profile
    </button>
  )
}

Analytics decoupling

tsx
function AnalyticsListener() {
  useEventBus(appEvents, 'analytics:track', ({ event, properties }) => {
    if (typeof window !== 'undefined' && window.gtag) {
      window.gtag('event', event, properties)
    }
  })
 
  return null
}
 
// Any feature — no direct analytics import
appEvents.publish('analytics:track', {
  event: 'purchase_complete',
  properties: { orderId: 'ord_123', total: 99.99 },
})

Micro-frontend shared bus

tsx
// packages/shared-events/index.ts — consumed by shell + remotes
export const shellEvents = new EventBus<{
  'nav:change': { path: string }
  'cart:updated': { count: number }
}>()
 
// Remote checkout MFE
shellEvents.publish('cart:updated', { count: 3 })
 
// Shell app header
shellEvents.subscribe('cart:updated', ({ count }) => updateBadge(count))

Performance

Analytics listeners should batch or debounce high-frequency events (scroll, mousemove) — publish a throttled topic or aggregate in the handler before sending to third-party SDKs.

Back to index


9. Best practices & pitfalls

Anti-patterns

Anti-patternProblemFix
Hidden global state nightmareEvent bus becomes undebuggable app stateContext or store for readable state
Event explosionToo many publishes — perf + noiseBatch, throttle, consolidate topics
Circular event chainsA → B → C → A infinite loopIdempotent handlers, loop guards
Missing unsubscribeMemory leaks on route changeuseEventBus with effect cleanup
Untyped event namesTypos silently failTyped EventBus<AppEvents> map

Warning

Do not store authoritative application state only in event payloads with no single source of truth. If CartBadge reads count from events but never syncs with the server cache, tabs and refreshes desync. Events signal; Context or Query holds state.

Warning

Circular event chains — cart:added handler publishes analytics:track which triggers cart:sync which publishes cart:added again — create a diagram of event flows before adding new topics.

Tip

Production checklist

  • Typed event map with namespace prefixes
  • subscribe returns unsubscribe — always cleanup in useEffect
  • useEventBus hook for React components
  • One ToastHost / AnalyticsListener mounted in root layout
  • Pub-Sub for side effects; Context for renderable shared state
  • BroadcastChannel with loop prevention for cross-tab
  • Document event catalog in events.ts or team wiki
  • Profile handler cost before adding high-frequency topics

Note

When migrating from Observer callbacks to Pub-Sub, start with one vertical slice (toasts) before moving cart or auth — proves the bus API and cleanup patterns without a big-bang refactor.

Back to index


Summary

PatternCouplingBest for
ObserverSubject knows observersWidget callbacks, small known listener sets
Pub-SubDecoupled via Event BusToasts, analytics, micro-frontends, cross-tab

Observer and Pub-Sub enable cross-component communication without props or Context. Implement a typed Event Bus, wrap subscriptions in useEventBus for cleanup, and reserve the pattern for side effects — not as a hidden global state layer.

Next reads: Provider Pattern for tree-bound shared state, State Reducer Pattern for predictable transitions, and React Design Patterns for the full pattern roadmap.

Share this article

XLinkedInFacebook
Kazi Rahamatullah

Written by

Kazi Rahamatullah

FullStack Developer

X / TwitterGitHubLinkedIn

Subscribe to my newsletter

Stay up to date and get notified when I share new contents.

No spam ever, unsubscribe anytime