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.
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:
- Observer pattern — Subject, observers, one-to-many, tight coupling
- Pub-Sub pattern — publisher, subscriber, message broker, topic-based decoupling
- Pattern comparison — when each fits in React
- Event Bus implementation — subscribe, publish, typed events
useEventBushook — lifecycle cleanup, memory-leak prevention- Cross-tab broadcasting —
BroadcastChannelAPI, loop prevention - Pub-Sub vs Context — tree-bound vs disjoint communication
- Use cases — toasts, analytics, micro-frontends
- Best practices & pitfalls — hidden global state, event explosion, circular chains
Quick index
1. Observer design pattern
The Observer pattern defines a Subject that maintains a list of Observers and notifies them when its state changes.
Subject (Observable)
├── Observer A ← notified on change
├── Observer B ← notified on change
└── Observer C ← notified on change| Characteristic | Meaning |
|---|---|
| Subject and observers | Subject knows its observers directly |
| One-to-many relationship | One subject → many observers |
| Subject tracks dependencies | Subject registers/unregisters observers dynamically |
| Tightly coupled architecture | Observers typically reference or are referenced by the subject |
| Notification with payload | Subject passes changed data when notifying |
Classic Subject implementation
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 })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.
Publisher A ──┐
Publisher B ──┼──► Event Bus (topics) ──┬──► Subscriber 1
Publisher C ──┘ ├──► Subscriber 2
└──► Subscriber 3| Characteristic | Meaning |
|---|---|
| Publisher, subscriber, broker | Three roles — communication via the bus |
| Many-to-many relationship | Any publisher can reach any subscriber on a topic |
| Decoupled entities (topic-based) | Components communicate by event name, not import |
| Asynchronous event communication | Handlers can defer work; publishers do not await |
| Event Bus as middleman | Central registry maps event names → handler sets |
// 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))3. Pub-Sub vs Observer in React
Both patterns solve cross-component signaling; they differ in coupling and composition.
| Dimension | Observer | Pub-Sub |
|---|---|---|
| Coupling | Subject knows observers (tighter) | Publisher/subscriber decoupled via broker |
| Composition | Often parent → child callback props | Any component, any tree depth, via Event Bus |
| Component isolation | Weaker — direct references | Stronger — topic-only contract |
| Best for | Widget internals, 1–3 known listeners | App-wide side effects, micro-frontends |
| Cross-tree comms | Awkward without lifting state | Natural — no props or Context required |
Observer in React — callback prop
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
// 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
}4. Event Bus implementation
A typed Event Bus maps event names to handler sets with subscribe (returns unsubscribe) and publish (iterates handlers with payload).
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
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
// 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>
}5. useEventBus custom hook
Wrap subscription in a hook so React automatically unsubscribes on unmount — preventing memory leaks.
'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
'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
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<ToastHost />
<AnalyticsListener />
</body>
</html>
)
}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.
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
'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>
)
}7. Pub-Sub vs React Context
Context and Pub-Sub both share data across components — but they solve different problems.
| Dimension | React Context | Pub-Sub / Event Bus |
|---|---|---|
| Tree binding | Tied to React component hierarchy | Works across disjoint trees |
| Data model | Current value all consumers can read | Ephemeral events — no built-in "current state" |
| Re-renders | All context consumers re-render on change | Subscribers run handlers — no automatic UI |
| Framework | React-specific | Framework-agnostic utility |
| Independent apps | Cannot cross React roots easily | Micro-frontends share a bus module |
// 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
}8. Primary use cases
| Use case | Pattern | Example event |
|---|---|---|
| Global toast notifications | Pub-Sub | toast:show → ToastHost in layout |
| Analytics and logging | Pub-Sub | analytics:track → Segment listener |
| Micro-frontend communication | Pub-Sub | Shared appEvents npm package |
| Modal / command palette | Pub-Sub | modal:open, command:run |
| Form field coordination | Observer | Parent callbacks to sibling validators |
| External store subscription | Observer | store.subscribe() in useEffect |
Toast notification system
'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
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
// 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))9. Best practices & pitfalls
Anti-patterns
| Anti-pattern | Problem | Fix |
|---|---|---|
| Hidden global state nightmare | Event bus becomes undebuggable app state | Context or store for readable state |
| Event explosion | Too many publishes — perf + noise | Batch, throttle, consolidate topics |
| Circular event chains | A → B → C → A infinite loop | Idempotent handlers, loop guards |
| Missing unsubscribe | Memory leaks on route change | useEventBus with effect cleanup |
| Untyped event names | Typos silently fail | Typed EventBus<AppEvents> map |
Summary
| Pattern | Coupling | Best for |
|---|---|---|
| Observer | Subject knows observers | Widget callbacks, small known listener sets |
| Pub-Sub | Decoupled via Event Bus | Toasts, 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.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime