useBroadcastChannel
An SSR-safe hook that broadcasts messages between same-origin tabs over BroadcastChannel, with capability detection and an optional local echo.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-broadcast-channel.jsonPrompt
Build a React + TypeScript "useBroadcastChannel" hook (no dependencies beyond
React; wraps the native BroadcastChannel API).
Contract
- `useBroadcastChannel<T = unknown>(name: string, options?: {
onMessage?: (data: T, meta: { local: boolean }) => void
onMessageError?: (event: MessageEvent) => void
echo?: boolean // default false
}): { post: (data: T) => void; isSupported: boolean; lastMessage: T | undefined }`
- `name` is the channel name — every context in the same origin that opens a
channel with the same name is a peer.
- `post(data)` broadcasts; its identity is stable across re-renders (it only
changes when `name` changes), so it is safe inside dependency arrays.
- `isSupported` is `false` during SSR and on the hydration pass, then reflects
the real environment.
- `lastMessage` is the most recently received payload, `undefined` until the
first one arrives; changing `name` resets it to `undefined`.
- `meta.local` tells the consumer where a message came from: `false` = another
tab/window/worker, `true` = this page's own `post()` replayed by `echo`.
Behavior
- **The sender never receives its own message.** That is the native
BroadcastChannel contract, not a design choice: a channel object is excluded
from the messages it posts. Note the granularity — *another hook instance on
the same page* is a different channel object and does receive them; only the
instance that called `post` is skipped. So the realistic pattern is "update
my own state locally, then broadcast the new value to everyone else".
- **`echo` (default false) is the opt-in escape hatch**: when on, `post(data)`
also replays the payload into this page through the exact same delivery path
(`setLastMessage` + `onMessage(data, { local: true })`), for consumers that
want a single funnel driving local state. Keep it off by default — the tab
that initiated the action has usually already applied it, and replaying would
run the effect twice. `echo` also works when `isSupported` is false: its
meaning is "deliver to me too", which is independent of the transport.
One asymmetry to state plainly: an echoed payload is the *same object
reference* the caller passed (it never goes through structured clone), while a
remote peer receives a clone — so treat received messages as immutable, or a
mutation in `onMessage` will diverge the local tab from every other tab.
- **Capability detection never happens in the render body.** Use
`useSyncExternalStore(subscribeToNothing, () => typeof BroadcastChannel !==
"undefined", () => false)`: a never-changing external value, so `subscribe`
returns a no-op cleanup. The separate server snapshot is not ceremony —
Node 18+ exposes a *global* `BroadcastChannel` (from `node:worker_threads`),
so a naive shared snapshot would report `true` during SSR while pointing at
an in-process channel that has nothing to do with the browser. Pinning the
server snapshot to `false` makes the first paint render the degraded UI and
avoids a hydration mismatch.
- **Callbacks go through latest-refs and stay out of dependency arrays.**
`onMessage` / `onMessageError` / `echo` are synced into refs by a dependency-free
effect; the effect that opens the channel depends on `[name]` only. Consumers
overwhelmingly pass inline arrow functions, and a callback in the dependency
array would close and reopen the channel on every render — messages posted
during that gap are simply lost.
- **Lifecycle.** One channel per `name`: create it in an effect, keep it in a
ref, and on cleanup remove both listeners, call `channel.close()` and null the
ref. A channel left open keeps firing into an unmounted tree. When `name`
changes, close the old channel, open the new one, and reset `lastMessage`
through a render-phase state adjustment (compare a tracked previous `name`) —
not a `setState` inside an effect body.
- **Both structured-clone failure modes have an exit; neither is swallowed.**
Sending side: `channel.postMessage(data)` throws `DataCloneError`
synchronously for functions, DOM nodes, class instances carrying methods and
Proxies — catch it and rethrow a named `Error` that says which channel failed,
what is not cloneable and what to send instead, with the original error as
`cause`. Receiving side: the native `messageerror` event fires when a payload
cannot be deserialized here; call `onMessageError` if provided, otherwise
`console.error` a diagnostic — never drop it, or messages vanish with no trace.
- **Degrading when unsupported.** With no BroadcastChannel, `post` skips the
transport instead of throwing, so a missing sync degrades to "that
notification did not happen" rather than crashing every call site; consumers
that need an explicit branch read `isSupported`.
Rendering & styling
- The hook renders nothing — it returns `{ post, isSupported, lastMessage }`.
Consumers own all UI: use semantic tokens (`bg-card`, `text-foreground`,
`text-muted-foreground`, `text-destructive`, `border`) for anything that
visualizes channel state, gate controls on `isSupported` rather than hiding
them, and format timestamps with an explicit locale (`toLocaleTimeString("en-US")`)
so server and client agree.
Customization levers
- Message envelope — instead of a bare payload, broadcast
`{ type, payload, senderId }` and let `onMessage` switch on `type`; a
`senderId` created once per tab (`useId` or a module-level random string) lets
you ignore messages from peers you do not care about, and is the only way to
identify a specific sender since BroadcastChannel carries no origin info.
- Leader election / single-tab lock — on top of the same channel, answer a
`"who-is-leader"` broadcast and let the first responder win; useful for
running a poller or a websocket in exactly one tab.
- Request/response — pair each `post` with a `requestId` and resolve a pending
promise when a message with the same id comes back, turning the fire-and-forget
channel into an ask.
- Replace the transport — the same contract (`post` / `isSupported` /
`lastMessage` / `onMessage`) can sit on a `localStorage`-write-and-listen
fallback for environments without BroadcastChannel, or on a `SharedWorker`
when peers must also survive a tab close.
- `echo` per call — if some posts should replay locally and others should not,
move the flag from the options object to a second `post(data, { echo })`
argument; keep the options-level value as the default.Concepts
- Sender excluded from its own message — a BroadcastChannel object never receives what it posts; the exclusion is per channel object, so a second
useBroadcastChannelinstance on the same page is a peer and does hear it. The practical consequence: the tab that acts must apply the change to itself directly, and the broadcast is purely "tell everyone else". - Optional local echo —
echo: truereplays a post into the current page through the identical delivery path, soonMessagesees local and remote messages in the same shape (meta.localis the only difference). It exists for consumers that want one funnel driving state; leaving it off is the honest default, because double-applying an action the initiator already performed is the more common bug. - Capability detection off the render path —
typeof BroadcastChannelis read insideuseSyncExternalStore's snapshot, never in the render body, and the server snapshot is hard-coded tofalsebecause Node 18+ has a globalBroadcastChannelthat would otherwise make SSR claim support for a channel no browser tab is listening on. - latest-ref callbacks,
name-only dependencies — the channel effect depends on[name]alone;onMessage/onMessageError/echolive in refs refreshed after every render. Putting a callback in the dependency array would close and reopen the channel whenever a consumer passes an inline arrow function, and every message posted inside that gap is lost with no error anywhere. - Structured-clone boundary, two exits — the send side throws a named error (original
DataCloneErroroncause) when the payload is not cloneable; the receive side gets a nativemessageerrorwhen a payload cannot be reconstructed locally, routed toonMessageErroror, failing that,console.error. Silently swallowing either turns "my message disappeared" into an unfixable bug report. - Close on unmount, reset on rename — cleanup removes both listeners and calls
channel.close(), because an open channel keeps delivering into a dead tree; a changednameis a different channel, solastMessageis cleared through a render-phase state adjustment rather than asetStatein an effect body.
usePageVisibility
An SSR-safe hook that subscribes to document visibility so polling, timers and video can pause when the tab goes to the background, and reports how long the user was away.
useUndoRedo
A state container with linear undo/redo history — keystroke coalescing, forced breakpoints, a bounded stack, and stable callbacks.