Hooks

useBroadcastChannel

An SSR-safe hook that broadcasts messages between same-origin tabs over BroadcastChannel, with capability detection and an optional local echo.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

export interface UseBroadcastChannelOptions<T> {
  /**
   * Called on every incoming message. `meta.local` tells you where it came from:
   * `false` = another tab/window/worker, `true` = this page's own `post` replayed by
   * `echo` (see below).
   *
   * Kept in a latest-ref, synced on every render and **never in an effect dependency
   * array** — consumers pass an inline arrow (the common case), which is a new reference
   * every render, and depending on it would close/re-open the channel constantly,
   * dropping every message that arrives during the gap.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-broadcast-channel.json

Prompt

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 useBroadcastChannel instance 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 echoecho: true replays a post into the current page through the identical delivery path, so onMessage sees local and remote messages in the same shape (meta.local is 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 pathtypeof BroadcastChannel is read inside useSyncExternalStore's snapshot, never in the render body, and the server snapshot is hard-coded to false because Node 18+ has a global BroadcastChannel that 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 / echo live 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 DataCloneError on cause) when the payload is not cloneable; the receive side gets a native messageerror when a payload cannot be reconstructed locally, routed to onMessageError or, 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 changed name is a different channel, so lastMessage is cleared through a render-phase state adjustment rather than a setState in an effect body.

On This Page