IdanShalem/react-broadcast-sync

Sync state and events across browser tabs in React - a lightweight, type-safe hook over the BroadcastChannel API

TypeScript

22

69 commits

updated Oct 12, 2026

See the code

See what people are saying

SourceMessageScoreDate

Syncing dashboard filters across React tabs without a broadcast loop (r/reactjs)

You change a dashboard filter in one tab, switch to another, and it still shows the old selection. Fetching the same API data doesn't make the UI state shared. I maintain react-broadcast-sync, a React wrapper around BroadcastChannel. The important part of my dashboard-filter example is keeping two…

0

Oct 8, 2026

README

react-broadcast-sync logo

react-broadcast-sync

CI Coverage NPM Version NPM Downloads Documentation Bundle size License: MIT GitHub issues

Guide: Using react-broadcast-sync in a real app: dashboard filters across tabs - install, usage patterns, gotchas and recipes.

Easily sync UI state or user events across browser tabs in React apps — notifications, presence, forms, and more. This package provides a clean and type-safe abstraction over the native API, enabling efficient, scoped, and reliable cross-tab messaging.

When to use it: React apps that need to share ephemeral state or events between browser tabs of the same origin (logout, notifications, presence, draft sync). It wraps the browser BroadcastChannel API in a hook (useBroadcastChannel) and an optional BroadcastProvider.

When not to use it: syncing to a server or other devices, persisting data across reloads (use localStorage/IndexedDB), or messaging across different origins.

import { useBroadcastChannel } from 'react-broadcast-sync';

const { messages, postMessage } = useBroadcastChannel('my-channel');
postMessage('logout', { at: Date.now() }); // other tabs receive it in `messages`

React Broadcast Sync Demo

Table of Contents

Features

  • Simple and intuitive API
  • Real-time synchronization across tabs
  • TypeScript support
  • Zero dependencies
  • Automatic message expiration and cleanup
  • Send/receive any serializable message
  • Namespace and source scoping support
  • Clear individual or all messages
  • Only accept allowed message types (optional)
  • BroadcastProvider for context-based usage with full options support
  • Ping and active source detection (discover other tabs and their source names)
  • Per-type onMessage callbacks (react to incoming messages without polling state)

Why Not localStorage?

The first instinct for sharing state between tabs is often localStorage plus the storage event. It works for simple cases, but it was designed for persistence, not messaging - and the ecosystem offers a few different trade-offs. Here's how react-broadcast-sync compares:

react-broadcast-synclocalStorage + storage eventsjotai atomWithStoragebroadcast-channelzustand-sync-tabs
What it syncsTyped messages on named channelsRaw key-value stringsA single persisted atom valueMessages on a channelA Zustand store's state
React integrationFirst-class hook + providerManual event listenersRequires JotaiNone - framework agnosticRequires Zustand
PayloadsAny serializable valueStrings only (manual JSON)JSON-serializable valueAny serializable valueStore state
Message lifecycleExpiration, deduplication, batching, selective clearingNoneNoneNoneLatest state only
Tab awarenessSource naming, ping & active-source detectionNone (no event in the tab that wrote)NoneLeader electionNone
Extra environmentsBrowsers (same origin)Browsers (same origin)React Native via AsyncStorageOld browsers, WebWorkers, Node, DenoBrowsers (same origin)

A few honest notes:

  • If you need persistence across reloads, use a storage-based tool (atomWithStorage, plain localStorage). Messages in react-broadcast-sync are ephemeral - they notify and sync live tabs, they don't survive a refresh.
  • If you need old-browser, WebWorker, or Node support, broadcast-channel is the right low-level primitive. react-broadcast-sync builds on the native BroadcastChannel API and gives you the React layer on top: hooks, typed messages, and lifecycle handling out of the box.
  • If your app already lives in Zustand or Jotai, their sync utilities may be enough. react-broadcast-sync is state-manager agnostic - it works next to any of them, or with plain useState.

Documentation

Full documentation lives at react-broadcast-sync-docs.vercel.app: a step-by-step getting started guide, concepts, per-feature guides, the complete API reference, recipes, a tutorial, FAQ, and troubleshooting.

Two live demos are hosted on the docs site:

  • Hook demo - useBroadcastChannel syncing a counter, text, and a todo list across tabs.
  • Provider demo - BroadcastProvider + useBroadcastProvider sharing one channel through context.

The original demo app is still available at react-broadcast-sync-3w3m.vercel.app.

Demo App

Check out our live demo to see the library in action! The demo showcases three main features:

  1. Counter Synchronization

    • Real-time counter updates across tabs
    • Visual feedback for sync status
    • Smooth animations
  2. Text Synchronization

    • Real-time text input sync
    • Multi-line support
    • Instant updates
  3. Todo List

    • Synchronized todo items
    • Real-time hover effects
    • Scroll position sync
    • Completion status sync
    • Delete functionality

The demo is built with React 19, TypeScript, Material-UI, and Framer Motion. You can find the source code in the demo directory.


Installation

npm install react-broadcast-sync
# or
yarn add react-broadcast-sync
# or
pnpm add react-broadcast-sync

Quick Start

Basic Usage

import { useBroadcastChannel } from 'react-broadcast-sync';

function MyComponent() {
  const { messages, postMessage, clearReceivedMessages } = useBroadcastChannel('my-channel');

  const handleSend = () => {
    postMessage('greeting', { text: 'Hello from another tab!' });
  };

  return (
    <>
      <button onClick={handleSend}>Send</button>
      {messages.map(msg => (
        <div key={msg.id}>
          {msg.message.text}
          <button onClick={() => clearReceivedMessages({ ids: [msg.id] })}>Clear</button>
        </div>
      ))}
    </>
  );
}

Advanced Usage

const {
  channelName,
  messages,
  sentMessages,
  postMessage,
  clearReceivedMessages,
  clearSentMessages,
  error,
} = useBroadcastChannel('my-channel', {
  sourceName: 'my-tab',
  cleaningInterval: 2000,
  keepLatestMessage: true,
  registeredTypes: ['greeting', 'notification'],
  namespace: 'my-app',
  deduplicationTTL: 10 * 60 * 1000, // 10 minutes
  cleanupDebounceMs: 500, // Debounce cleanup operations by 500ms
});

Sending a message with expiration:

postMessage('notification', { text: 'This disappears in 5s' }, { expirationDuration: 5000 });

Using BroadcastProvider

You can wrap part of your app with BroadcastProvider and use useBroadcastProvider() to consume the channel context.

import { BroadcastProvider, useBroadcastProvider } from 'react-broadcast-sync';

function App() {
  return (
    <BroadcastProvider channelName="notifications">
      <NotificationBar />
    </BroadcastProvider>
  );
}

function NotificationBar() {
  const { messages } = useBroadcastProvider();

  return (
    <div>
      {messages.map(msg => (
        <p key={msg.id}>{msg.message.text}</p>
      ))}
    </div>
  );
}

BroadcastProvider Props

PropTypeRequiredDescription
channelNamestring✅The name of the broadcast channel
optionsBroadcastOptions❌Any option accepted by useBroadcastChannel
childrenReact.ReactNode✅Component subtree

All fields in BroadcastOptions (see the table in the API Reference) can be forwarded via the options prop. This includes namespace, registeredTypes, onMessage, keepLatestMessage, sourceName, and more.

<BroadcastProvider
  channelName="notifications"
  options={{
    namespace: 'v2',
    registeredTypes: ['alert', 'info'],
    onMessage: {
      alert: msg => showToast(msg.message.text),
    },
  }}
>
  <App />
</BroadcastProvider>

Syncing Shared State (filters, settings)

messages is an event log of what other tabs sent, not the current state. To keep a piece of state (for example dashboard filters) in sync, update local state and send one message from the handler, and apply incoming messages with onMessage:

function Dashboard() {
  const [filters, setFilters] = useState({ status: 'all' });

  const { postMessage } = useBroadcastChannel('dashboard', {
    // sourceName is optional: a unique one is generated for each tab.
    // Do not build it inline with crypto.randomUUID(): it would change on every render.
    onMessage: {
      'filters-update': msg => setFilters(msg.message),
    },
  });

  const updateFilters = (next: typeof filters) => {
    setFilters(next);
    postMessage('filters-update', next);
  };

  return <FilterBar value={filters} onChange={updateFilters} />;
}

Do not call postMessage from a useEffect on state that you also set from incoming messages. Each tab re-broadcasts what it receives, the tabs ping-pong, and they can end up with different values. Deduplication is by message ID, so it does not stop this.

New tabs do not receive past messages

Messages are not replayed. A tab opened later only sees messages sent after it opened, and keepLatestMessage and getLatestMessage do not change that. To give a new tab the current state, let it ask for it and let an existing tab answer:

const { postMessage } = useBroadcastChannel('dashboard', {
  sourceName: sourceName, // unique per tab
  onMessage: {
    'filters-update': msg => setFilters(msg.message),
    'state-request': () => postMessage('filters-update', filtersRef.current),
  },
});

// Ask once when this tab mounts
useEffect(() => {
  postMessage('state-request', null);
}, []);

Keep the current value in a ref (filtersRef.current = filters) so the state-request handler always answers with the latest state. If no other tab is open, nothing answers and the tab keeps its default state.



API Reference

useBroadcastChannel Hook

const {
  channelName,
  messages,
  sentMessages,
  postMessage,
  clearReceivedMessages,
  clearSentMessages,
  error,
} = useBroadcastChannel(channelName, options);

Options

interface BroadcastOptions {
  sourceName?: string; // Custom name for the message source. Must be unique per tab: a tab ignores messages from its own sourceName
  cleaningInterval?: number; // Interval in ms for cleaning expired messages (default: 1000)
  keepLatestMessage?: boolean; // Keep only the latest message (default: false)
  registeredTypes?: string[]; // List of allowed message types
  namespace?: string; // Channel namespace for isolation
  deduplicationTTL?: number; // Time in ms to keep message IDs for deduplication (default: 5 minutes)
  cleanupDebounceMs?: number; // Debounce time in ms for cleanup operations (default: 0)
  batchingDelayMs?: number; // Delay in ms to batch outgoing messages (default: 20). If > 0, messages are batched and sent together.
  excludedBatchMessageTypes?: string[]; // Message types to always send immediately, never batched (default: []).
  onMessage?: MessageCallback | OnMessageMap; // Callback(s) fired when a received message passes all filters (default: undefined).
  telemetry?: boolean; // Deprecated no-op.
}

Default Values

OptionDefault ValueDescription
sourceNameundefinedAuto-generated if not provided
cleaningInterval10001 second between cleanup runs
keepLatestMessagefalseKeep all messages by default
registeredTypes[]Accept all message types by default
namespace''No namespace by default
deduplicationTTL3000005 minutes (5 × 60 × 1000 ms)
cleanupDebounceMs0No debounce by default
batchingDelayMs20Batch delay in ms (0 = off)
excludedBatchMessageTypes[]Types never batched
onMessageundefinedCallback(s) for received messages
telemetryfalseDeprecated no-op. No telemetry is collected

Return Value

interface BroadcastActions {
  channelName: string; // The resolved channel name (includes namespace)
  messages: BroadcastMessage[]; // Received messages
  sentMessages: BroadcastMessage[]; // Messages sent by this instance
  postMessage: (type: string, content: any, options?: SendMessageOptions) => void;
  clearReceivedMessages: (opts?: { ids?: string[]; types?: string[]; sources?: string[] }) => void;
  clearSentMessages: (opts?: { ids?: string[]; types?: string[]; sync?: boolean }) => void;
  getLatestMessage: (opts?: { type?: string; source?: string }) => BroadcastMessage | null;
  closeChannel: () => void;
  error: string | null; // Current error state
}

useBroadcastChannel(channelName, options?)

Returns an object with:

PropertyTypeDescription
channelNamestringThe resolved channel name (includes namespace)
messagesBroadcastMessage[]All received messages from other tabs
sentMessagesBroadcastMessage[]Messages sent from this tab
postMessage()functionSend a message to all tabs
clearReceivedMessages()functionClear received messages. No filters ⇒ clear all. With filters, a message is deleted only if it matches every provided filter (ids, types, sources). Empty arrays act as wildcards.
clearSentMessages()functionClear messages this tab sent (same matching rules). Pass sync: true to broadcast the clear to other tabs.
getLatestMessage()functionGet the latest message matching optional filters (type, source). Returns the most recent message that matches, or null if none.
ping(timeoutMs?)functionPing other tabs on the channel and collect their source names. timeoutMs (default: 300ms) controls how long to wait for responses before resolving. Returns a Promise of string array.
isPingInProgressbooleantrue while a ping is active, otherwise false.
closeChannel()functionExplicitly closes the broadcast channel and removes event listeners. Safe to call multiple times.
errorstring | nullAny runtime error from the channel

Clearing examples

// Clear everything we've received
clearReceivedMessages();

// Clear all messages we sent and broadcast the clear to other tabs
clearSentMessages({ sync: true });

// Clear by id or by type (OR inside each array)
clearReceivedMessages({ ids: ['123'] }); // id match
clearReceivedMessages({ types: ['alert', 'chat'] }); // type match

// Combine filters (logical AND between filters)
// Removes messages whose id is '123' AND type is 'alert'
clearSentMessages({ ids: ['123'], types: ['alert'] });

Send Options:

interface SendMessageOptions {
  expirationDuration?: number; // TTL in ms
  expirationDate?: number; // Exact expiry timestamp
}

Message Format:

interface BroadcastMessage {
  id: string;
  type: string;
  message: any;
  timestamp: number;
  source: string;
  expirationDate?: number;
}

Getting the Latest Message

You can use getLatestMessage to retrieve the most recent message received, optionally filtered by type and/or source. If no options are provided, it returns the latest message of any kind. If no message matches, it returns null.

Signature:

getLatestMessage(options?: { type?: string; source?: string }): BroadcastMessage | null

Examples:

// Get the latest message of any type/source
const latest = getLatestMessage();

// Get the latest message of a specific type
const latestAlert = getLatestMessage({ type: 'alert' });

// Get the latest message from a specific source
const latestFromTab = getLatestMessage({ source: 'tab-123' });

// Get the latest message of a specific type from a specific source
const latestInfoFromTab = getLatestMessage({ type: 'info', source: 'tab-123' });

// Check if there are any messages of a type
if (getLatestMessage({ type: 'notification' })) {
  // ...
}

Behavior:

  • If no messages are present, returns null.
  • If no message matches the filter, returns null.
  • If multiple messages match, returns the most recently received one.

Ping & Active Source Detection

useBroadcastChannel provides a ping method and an isPingInProgress state for discovering active sources (tabs) on the same channel.

  • ping(timeoutMs?: number): Promise<string[]>: Broadcasts a ping and collects responses from other tabs within the timeout. Returns an array of source names (excluding your own).
  • isPingInProgress: boolean: Indicates if a ping is currently in progress.

Example:

const { ping, isPingInProgress } = useBroadcastChannel('my-channel', {
  sourceName: 'my-tab',
});

// To discover other active sources:
const activeSources = await ping(300); // e.g., ['tab-2', 'tab-3']

// To show loading state:
if (isPingInProgress) {
  // Show spinner or status
}

Closing the Channel Explicitly

You can use closeChannel to explicitly close the underlying BroadcastChannel and remove all event listeners. This is useful if you want to clean up resources before the component unmounts, or to stop all cross-tab communication on demand. Note that the channel will automatically close and all event listeners will be removed when the component unmounts, so this method is mainly useful for manual cleanup.

Signature:

closeChannel(): void

Example:

const { closeChannel } = useBroadcastChannel('my-channel');

// ... later, when you want to stop all communication:
closeChannel();

Notes:

  • After calling closeChannel, the channel is closed and will not send or receive any more messages.
  • It is safe to call closeChannel multiple times (idempotent).
  • You do not need to call this for normal React unmounting; the hook will clean up automatically. Use it for explicit/manual cleanup only.

onMessage Callbacks

onMessage lets you react to incoming messages imperatively — without polling messages state or using useEffect. The callback fires after the message is added to state and only for messages that pass all active filters (registeredTypes, expiry, deduplication, self-filter). Internal protocol messages (PING, PONG, CLEAR_SENT_MESSAGES) never trigger it.

Two supported shapes:

// 1. A single catch-all function — fires for every accepted message type
type MessageCallback = (msg: BroadcastMessage) => void;

// 2. A map from message type → one handler function
type OnMessageMap = { [type: string]: (msg: BroadcastMessage) => void };

Catch-all example:

const { postMessage } = useBroadcastChannel('my-channel', {
  onMessage: msg => {
    console.log('received', msg.type, msg.message);
  },
});

Per-type map example:

useBroadcastChannel('my-channel', {
  onMessage: {
    error: msg => showToast(`Error: ${msg.message.text}`),
    success: msg => celebrate(),
    log: msg => console.log('[log]', msg.message),
  },
});

Behavior:

  • Fires for messages from other tabs only — self-messages are always ignored before this point.
  • Fires after messages state is updated — both the callback argument and messages[messages.length - 1] will be the same object.
  • A type not listed in the map causes no error — it is silently skipped.
  • If the callback throws, the error is caught and debug-logged. Message state is not affected.
  • Changing onMessage between renders is safe — the latest callback is always used with no stale closure risk.
  • Works with batched messages: each message in a batch triggers its own callback call.

telemetry Option (deprecated)

The package no longer collects any telemetry. The telemetry option is still accepted as a deprecated no-op so existing code keeps compiling; it will be removed in the next major version.


Agent Skill (optional)

The package ships an agent skill at skills/react-broadcast-sync/SKILL.md (the agentskills.io format) with safe recipes for coding agents. Nothing is installed automatically: there is no postinstall script and your agent settings are never modified. To use it, copy it into your project yourself:

mkdir -p .agents/skills
cp -r node_modules/react-broadcast-sync/skills/react-broadcast-sync .agents/skills/

Tools such as skills-npm can also extract it for you. Check your agent's docs for where it reads skills from.

Gotchas

  • Duplicate sourceName: a tab ignores any message whose source equals its own sourceName. If two tabs use the same sourceName, they will not receive each other's messages. Leave it unset (a unique name is generated) unless you need a stable name, and keep it unique per tab.
  • keepLatestMessage is one slot: with keepLatestMessage: true, messages holds only the single most recent message across all types, so a "b" message replaces a prior "a". Use registeredTypes or separate channels/namespaces if you need the latest message per type.
  • cleanupDebounceMs vs cleaningInterval: cleanup is debounced. If cleanupDebounceMs is longer than cleaningInterval, each interval tick restarts the debounce and expired messages may never be removed. Keep cleanupDebounceMs smaller than cleaningInterval.
  • Deduplication is by message ID, not content (see Message Deduplication).
  • Same origin only: BroadcastChannel does not cross origins, and a tab does not receive its own messages.
  • No telemetry: the telemetry option is a deprecated no-op (see Telemetry).

Best Practices

  • Use namespace to isolate functionality between different app modules.
  • Register allowed message types using registeredTypes to avoid processing unknown or irrelevant messages.
  • Always handle error state in UI or logs to detect channel failures.
  • Use keepLatestMessage: true if you only care about the most recent message (e.g. status updates).
  • Set appropriate deduplicationTTL based on your message frequency and importance.
  • Use cleanupDebounceMs when dealing with rapid message updates to prevent performance issues.
  • Use onMessage for imperative side-effects (toasts, analytics, logging) rather than useEffect on messages — it fires exactly once per accepted message, with no extra renders required.

Common Use Cases

Real-time Notifications

function NotificationSystem() {
  const { messages, postMessage } = useBroadcastChannel('notifications', {
    keepLatestMessage: true,
    registeredTypes: ['alert', 'info', 'warning'],
    deduplicationTTL: 60000, // 1 minute
  });

  return (
    <div>
      {messages.map(msg => (
        <Notification key={msg.id} type={msg.type} content={msg.message} />
      ))}
    </div>
  );
}

Multi-tab Form Synchronization

function FormSync() {
  const { messages, postMessage } = useBroadcastChannel('form-sync', {
    namespace: 'my-form',
    cleaningInterval: 5000,
  });

  const handleChange = (field: string, value: string) => {
    postMessage('field-update', { field, value }, { expirationDuration: 300000 }); // 5 minutes
  };

  return <Form onChange={handleChange} />;
}

Tab Status Synchronization

function TabStatus() {
  const { postMessage } = useBroadcastChannel('tab-status', {
    sourceName: 'main-tab',
    keepLatestMessage: true,
  });

  useEffect(() => {
    postMessage('tab-active', { timestamp: Date.now() });
    return () => postMessage('tab-inactive', { timestamp: Date.now() });
  }, []);

  return null;
}

Performance Considerations

Message Size

  • Keep messages small and serializable
  • Avoid sending large objects or circular references
  • Consider using message IDs to reference larger data

Message Frequency

  • Use keepLatestMessage: true for high-frequency updates
  • Implement debouncing for rapid state changes
  • Consider using expirationDuration for temporary messages

Batching Mechanism

Batching allows you to group multiple outgoing messages and send them together in a single post to the BroadcastChannel. This can significantly reduce the number of cross-tab events, improve performance, and avoid flooding the channel when many messages are sent in rapid succession (e.g., during fast typing or bulk updates).

  • batchingDelayMs: If set to a value greater than 0 (default: 20ms), outgoing messages are collected for up to this delay and then sent as an array. If 0 or negative, batching is disabled and all messages are sent immediately.
  • excludedBatchMessageTypes: An array of message types that should always be sent immediately, even if batching is enabled. Use this for urgent or high-priority messages (e.g., 'alert', 'sync-now').

How it works:

  • When batching is enabled, calls to postMessage within the batching window are buffered and sent as a batch (array of messages) after the delay.
  • On the receiving side, the hook automatically handles both single messages and batches (arrays). If you listen to the channel directly, always check if Array.isArray(event.data).
  • If the tab unmounts or the channel closes, any unsent batched messages are flushed immediately.

Why batching matters:

  • Reduces the number of events and improves efficiency, especially for high-frequency updates.
  • Prevents message storms that can occur when many updates happen in a short time.
  • Lets you control which messages are always sent immediately for real-time needs.

Example:

const { postMessage } = useBroadcastChannel('my-channel', {
  batchingDelayMs: 50, // Batch messages for up to 50ms
  excludedBatchMessageTypes: ['alert'], // Always send 'alert' immediately
});

// These will be batched if sent within 50ms
postMessage('edit', { field: 'a', value: 1 });
postMessage('edit', { field: 'b', value: 2 });
// This will be sent immediately
postMessage('alert', { message: 'Something happened!' });

Memory Management

  • Clear messages when they're no longer needed using clearReceivedMessages / clearSentMessages
  • Use cleaningInterval to automatically remove expired messages
  • Implement proper cleanup in component unmount

Message Deduplication

The deduplicationTTL option creates a time window (in milliseconds) during which a received message with an already-seen message ID is ignored. Deduplication is by ID, not by content: two separate postMessage calls with identical type and payload get different IDs and are both delivered. This is particularly useful for:

  • Preventing Message Loops: Avoids infinite message echo between tabs when they broadcast the same message back and forth
  • Reducing Redundancy: Drops the same message if it is delivered to a tab more than once within the window

Recommended TTL values based on use case:

  • High-frequency updates (e.g., real-time typing, cursor position): 1000-5000ms
  • Medium-frequency updates (e.g., form sync, status changes): 5000-15000ms
  • Low-frequency updates (e.g., notifications, alerts): 15000-30000ms

Example:

// Without deduplication, this could cause a message loop
function ChatComponent() {
  const { postMessage } = useBroadcastChannel('chat', {
    deduplicationTTL: 5000, // Ignore duplicate messages for 5 seconds
  });

  const handleMessage = (text: string) => {
    postMessage('chat-message', { text });
  };
}

Cleanup Optimization

  • Use cleanupDebounceMs to prevent excessive cleanup operations
  • Recommended values:
    • For frequent updates: 500-1000ms
    • For infrequent updates: 0ms (no debounce)
  • Adjust cleaningInterval based on your message expiration needs

Troubleshooting

Common Issues

  1. Messages Not Received

    • Check if registeredTypes includes your message type
    • Verify the channel name and namespace match
    • Ensure the message hasn't expired
    • Check if deduplicationTTL isn't too short
  2. Performance Issues

    • Increase cleanupDebounceMs if cleanup is too frequent
    • Use keepLatestMessage: true for high-frequency updates
    • Consider increasing cleaningInterval if cleanup is too aggressive
  3. Memory Leaks

    • Ensure proper cleanup in component unmount
    • Use message expiration for temporary data
    • Clear messages when they're no longer needed

Debug Mode

Enable debug logging by setting the environment variable:

REACT_APP_DEBUG_BROADCAST=true

This will log:

  • Channel creation and closure
  • Message sending and receiving
  • Cleanup operations
  • Error states

Testing

Unit Testing

import { renderHook, act } from '@testing-library/react-hooks';
import { useBroadcastChannel } from 'react-broadcast-sync';

test('postMessage records the message in sentMessages', () => {
  const { result } = renderHook(() => useBroadcastChannel('test-channel'));

  act(() => {
    result.current.postMessage('test', { data: 'hello' });
  });

  // The hook does not echo your own messages back into `messages`.
  // `messages` only holds messages sent by other tabs or hook instances.
  expect(result.current.messages).toHaveLength(0);
  expect(result.current.sentMessages).toHaveLength(1);
  expect(result.current.sentMessages[0].message.data).toBe('hello');
});

To test receiving, render two hooks with different sourceName values on the same channel name (this needs a BroadcastChannel implementation in the test environment, such as a mock that delivers to other instances). The message sent by one appears in the other's messages.

Integration Testing

import { render, screen } from '@testing-library/react';
import { BroadcastProvider } from 'react-broadcast-sync';

test('should render messages from provider', () => {
  render(
    <BroadcastProvider channelName="test-channel">
      <TestComponent />
    </BroadcastProvider>
  );

  // Your test assertions here
});

Running This Repository's Test Suites

The repository has two test suites:

# Unit tests (Jest + jsdom)
npm test

# Cross-tab integration tests (Playwright + Chromium, Firefox, and WebKit)
npx playwright install --with-deps chromium firefox webkit   # one-time browser install
npm run test:integration

The integration suite runs each test in Chromium, Firefox, and WebKit. It serves a small React app from integration/app and drives real browser tabs through the public hook API: message delivery between tabs, self-filtering, namespace isolation, registeredTypes filtering, ping source discovery, synced clearSentMessages, local clearReceivedMessages, and message expiration.

Browser Support

Relies on BroadcastChannel API:

  • ✅ Chrome 54+
  • ✅ Firefox 38+
  • ✅ Edge 79+
  • ✅ Safari 15.4+
  • ✅ Opera 41+

Telemetry

The package no longer collects any telemetry. The telemetry option is still accepted as a deprecated no-op so existing code keeps compiling; it will be removed in the next major version.


Coming Soon

We're actively improving react-broadcast-sync! Here are some features and enhancements planned for upcoming versions:

  • Automatic Channel Recovery
    Reconnect automatically if the BroadcastChannel gets disconnected or closed by the browser, with configurable retry delay and attempt cap.

  • Cross-tab History (withHistory)
    On connect, a new tab can request recent message history from already-open tabs. Includes chunked transfer, expiry filtering, and deduplication to prevent replaying stale or duplicate messages.

  • Integration Tests
    Real browser cross-tab tests using Playwright that go beyond what jsdom mocks can cover.

  • Anonymous Usage Telemetry
    Removed. The telemetry option remains as a deprecated no-op.

We're committed to keeping this package lightweight, flexible, and production-ready.
Your feedback and contributions are welcome — feel free to open an issue!


Versioning & Releases

This project uses Semantic Release for fully automated versioning and changelog generation.

Every push to the main branch with a Conventional Commit message triggers a release that includes:

  • ✅ Automatic semantic version bump (major, minor, or patch)
  • ✅ Changelog generation and publishing to GitHub Releases
  • ✅ Publishing to npm

Example Commit Messages

feat: add support for per-type callbacks
fix: debounce cleanup runs properly on tab reload
chore: update dependencies

---

## Contributing

PRs and feature suggestions welcome! Open an issue or submit a pull request.

---

## License

MIT © [Idan Shalem](https://github.com/IdanShalem)

IdanShalem/react-broadcast-sync

Sync state and events across browser tabs in React - a lightweight, type-safe hook over the BroadcastChannel API

TypeScript

22

69 commits

updated Oct 12, 2026

See the code

See what people are saying

SourceMessageScoreDate

Syncing dashboard filters across React tabs without a broadcast loop (r/reactjs)

You change a dashboard filter in one tab, switch to another, and it still shows the old selection. Fetching the same API data doesn't make the UI state shared. I maintain react-broadcast-sync, a React wrapper around BroadcastChannel. The important part of my dashboard-filter example is keeping two…

0

Oct 8, 2026

README

react-broadcast-sync logo

react-broadcast-sync

CI Coverage NPM Version NPM Downloads Documentation Bundle size License: MIT GitHub issues

Guide: Using react-broadcast-sync in a real app: dashboard filters across tabs - install, usage patterns, gotchas and recipes.

Easily sync UI state or user events across browser tabs in React apps — notifications, presence, forms, and more. This package provides a clean and type-safe abstraction over the native API, enabling efficient, scoped, and reliable cross-tab messaging.

When to use it: React apps that need to share ephemeral state or events between browser tabs of the same origin (logout, notifications, presence, draft sync). It wraps the browser BroadcastChannel API in a hook (useBroadcastChannel) and an optional BroadcastProvider.

When not to use it: syncing to a server or other devices, persisting data across reloads (use localStorage/IndexedDB), or messaging across different origins.

import { useBroadcastChannel } from 'react-broadcast-sync';

const { messages, postMessage } = useBroadcastChannel('my-channel');
postMessage('logout', { at: Date.now() }); // other tabs receive it in `messages`

React Broadcast Sync Demo

Table of Contents

Features

  • Simple and intuitive API
  • Real-time synchronization across tabs
  • TypeScript support
  • Zero dependencies
  • Automatic message expiration and cleanup
  • Send/receive any serializable message
  • Namespace and source scoping support
  • Clear individual or all messages
  • Only accept allowed message types (optional)
  • BroadcastProvider for context-based usage with full options support
  • Ping and active source detection (discover other tabs and their source names)
  • Per-type onMessage callbacks (react to incoming messages without polling state)

Why Not localStorage?

The first instinct for sharing state between tabs is often localStorage plus the storage event. It works for simple cases, but it was designed for persistence, not messaging - and the ecosystem offers a few different trade-offs. Here's how react-broadcast-sync compares:

react-broadcast-synclocalStorage + storage eventsjotai atomWithStoragebroadcast-channelzustand-sync-tabs
What it syncsTyped messages on named channelsRaw key-value stringsA single persisted atom valueMessages on a channelA Zustand store's state
React integrationFirst-class hook + providerManual event listenersRequires JotaiNone - framework agnosticRequires Zustand
PayloadsAny serializable valueStrings only (manual JSON)JSON-serializable valueAny serializable valueStore state
Message lifecycleExpiration, deduplication, batching, selective clearingNoneNoneNoneLatest state only
Tab awarenessSource naming, ping & active-source detectionNone (no event in the tab that wrote)NoneLeader electionNone
Extra environmentsBrowsers (same origin)Browsers (same origin)React Native via AsyncStorageOld browsers, WebWorkers, Node, DenoBrowsers (same origin)

A few honest notes:

  • If you need persistence across reloads, use a storage-based tool (atomWithStorage, plain localStorage). Messages in react-broadcast-sync are ephemeral - they notify and sync live tabs, they don't survive a refresh.
  • If you need old-browser, WebWorker, or Node support, broadcast-channel is the right low-level primitive. react-broadcast-sync builds on the native BroadcastChannel API and gives you the React layer on top: hooks, typed messages, and lifecycle handling out of the box.
  • If your app already lives in Zustand or Jotai, their sync utilities may be enough. react-broadcast-sync is state-manager agnostic - it works next to any of them, or with plain useState.

Documentation

Full documentation lives at react-broadcast-sync-docs.vercel.app: a step-by-step getting started guide, concepts, per-feature guides, the complete API reference, recipes, a tutorial, FAQ, and troubleshooting.

Two live demos are hosted on the docs site:

  • Hook demo - useBroadcastChannel syncing a counter, text, and a todo list across tabs.
  • Provider demo - BroadcastProvider + useBroadcastProvider sharing one channel through context.

The original demo app is still available at react-broadcast-sync-3w3m.vercel.app.

Demo App

Check out our live demo to see the library in action! The demo showcases three main features:

  1. Counter Synchronization

    • Real-time counter updates across tabs
    • Visual feedback for sync status
    • Smooth animations
  2. Text Synchronization

    • Real-time text input sync
    • Multi-line support
    • Instant updates
  3. Todo List

    • Synchronized todo items
    • Real-time hover effects
    • Scroll position sync
    • Completion status sync
    • Delete functionality

The demo is built with React 19, TypeScript, Material-UI, and Framer Motion. You can find the source code in the demo directory.


Installation

npm install react-broadcast-sync
# or
yarn add react-broadcast-sync
# or
pnpm add react-broadcast-sync

Quick Start

Basic Usage

import { useBroadcastChannel } from 'react-broadcast-sync';

function MyComponent() {
  const { messages, postMessage, clearReceivedMessages } = useBroadcastChannel('my-channel');

  const handleSend = () => {
    postMessage('greeting', { text: 'Hello from another tab!' });
  };

  return (
    <>
      <button onClick={handleSend}>Send</button>
      {messages.map(msg => (
        <div key={msg.id}>
          {msg.message.text}
          <button onClick={() => clearReceivedMessages({ ids: [msg.id] })}>Clear</button>
        </div>
      ))}
    </>
  );
}

Advanced Usage

const {
  channelName,
  messages,
  sentMessages,
  postMessage,
  clearReceivedMessages,
  clearSentMessages,
  error,
} = useBroadcastChannel('my-channel', {
  sourceName: 'my-tab',
  cleaningInterval: 2000,
  keepLatestMessage: true,
  registeredTypes: ['greeting', 'notification'],
  namespace: 'my-app',
  deduplicationTTL: 10 * 60 * 1000, // 10 minutes
  cleanupDebounceMs: 500, // Debounce cleanup operations by 500ms
});

Sending a message with expiration:

postMessage('notification', { text: 'This disappears in 5s' }, { expirationDuration: 5000 });

Using BroadcastProvider

You can wrap part of your app with BroadcastProvider and use useBroadcastProvider() to consume the channel context.

import { BroadcastProvider, useBroadcastProvider } from 'react-broadcast-sync';

function App() {
  return (
    <BroadcastProvider channelName="notifications">
      <NotificationBar />
    </BroadcastProvider>
  );
}

function NotificationBar() {
  const { messages } = useBroadcastProvider();

  return (
    <div>
      {messages.map(msg => (
        <p key={msg.id}>{msg.message.text}</p>
      ))}
    </div>
  );
}

BroadcastProvider Props

PropTypeRequiredDescription
channelNamestring✅The name of the broadcast channel
optionsBroadcastOptions❌Any option accepted by useBroadcastChannel
childrenReact.ReactNode✅Component subtree

All fields in BroadcastOptions (see the table in the API Reference) can be forwarded via the options prop. This includes namespace, registeredTypes, onMessage, keepLatestMessage, sourceName, and more.

<BroadcastProvider
  channelName="notifications"
  options={{
    namespace: 'v2',
    registeredTypes: ['alert', 'info'],
    onMessage: {
      alert: msg => showToast(msg.message.text),
    },
  }}
>
  <App />
</BroadcastProvider>

Syncing Shared State (filters, settings)

messages is an event log of what other tabs sent, not the current state. To keep a piece of state (for example dashboard filters) in sync, update local state and send one message from the handler, and apply incoming messages with onMessage:

function Dashboard() {
  const [filters, setFilters] = useState({ status: 'all' });

  const { postMessage } = useBroadcastChannel('dashboard', {
    // sourceName is optional: a unique one is generated for each tab.
    // Do not build it inline with crypto.randomUUID(): it would change on every render.
    onMessage: {
      'filters-update': msg => setFilters(msg.message),
    },
  });

  const updateFilters = (next: typeof filters) => {
    setFilters(next);
    postMessage('filters-update', next);
  };

  return <FilterBar value={filters} onChange={updateFilters} />;
}

Do not call postMessage from a useEffect on state that you also set from incoming messages. Each tab re-broadcasts what it receives, the tabs ping-pong, and they can end up with different values. Deduplication is by message ID, so it does not stop this.

New tabs do not receive past messages

Messages are not replayed. A tab opened later only sees messages sent after it opened, and keepLatestMessage and getLatestMessage do not change that. To give a new tab the current state, let it ask for it and let an existing tab answer:

const { postMessage } = useBroadcastChannel('dashboard', {
  sourceName: sourceName, // unique per tab
  onMessage: {
    'filters-update': msg => setFilters(msg.message),
    'state-request': () => postMessage('filters-update', filtersRef.current),
  },
});

// Ask once when this tab mounts
useEffect(() => {
  postMessage('state-request', null);
}, []);

Keep the current value in a ref (filtersRef.current = filters) so the state-request handler always answers with the latest state. If no other tab is open, nothing answers and the tab keeps its default state.



API Reference

useBroadcastChannel Hook

const {
  channelName,
  messages,
  sentMessages,
  postMessage,
  clearReceivedMessages,
  clearSentMessages,
  error,
} = useBroadcastChannel(channelName, options);

Options

interface BroadcastOptions {
  sourceName?: string; // Custom name for the message source. Must be unique per tab: a tab ignores messages from its own sourceName
  cleaningInterval?: number; // Interval in ms for cleaning expired messages (default: 1000)
  keepLatestMessage?: boolean; // Keep only the latest message (default: false)
  registeredTypes?: string[]; // List of allowed message types
  namespace?: string; // Channel namespace for isolation
  deduplicationTTL?: number; // Time in ms to keep message IDs for deduplication (default: 5 minutes)
  cleanupDebounceMs?: number; // Debounce time in ms for cleanup operations (default: 0)
  batchingDelayMs?: number; // Delay in ms to batch outgoing messages (default: 20). If > 0, messages are batched and sent together.
  excludedBatchMessageTypes?: string[]; // Message types to always send immediately, never batched (default: []).
  onMessage?: MessageCallback | OnMessageMap; // Callback(s) fired when a received message passes all filters (default: undefined).
  telemetry?: boolean; // Deprecated no-op.
}

Default Values

OptionDefault ValueDescription
sourceNameundefinedAuto-generated if not provided
cleaningInterval10001 second between cleanup runs
keepLatestMessagefalseKeep all messages by default
registeredTypes[]Accept all message types by default
namespace''No namespace by default
deduplicationTTL3000005 minutes (5 × 60 × 1000 ms)
cleanupDebounceMs0No debounce by default
batchingDelayMs20Batch delay in ms (0 = off)
excludedBatchMessageTypes[]Types never batched
onMessageundefinedCallback(s) for received messages
telemetryfalseDeprecated no-op. No telemetry is collected

Return Value

interface BroadcastActions {
  channelName: string; // The resolved channel name (includes namespace)
  messages: BroadcastMessage[]; // Received messages
  sentMessages: BroadcastMessage[]; // Messages sent by this instance
  postMessage: (type: string, content: any, options?: SendMessageOptions) => void;
  clearReceivedMessages: (opts?: { ids?: string[]; types?: string[]; sources?: string[] }) => void;
  clearSentMessages: (opts?: { ids?: string[]; types?: string[]; sync?: boolean }) => void;
  getLatestMessage: (opts?: { type?: string; source?: string }) => BroadcastMessage | null;
  closeChannel: () => void;
  error: string | null; // Current error state
}

useBroadcastChannel(channelName, options?)

Returns an object with:

PropertyTypeDescription
channelNamestringThe resolved channel name (includes namespace)
messagesBroadcastMessage[]All received messages from other tabs
sentMessagesBroadcastMessage[]Messages sent from this tab
postMessage()functionSend a message to all tabs
clearReceivedMessages()functionClear received messages. No filters ⇒ clear all. With filters, a message is deleted only if it matches every provided filter (ids, types, sources). Empty arrays act as wildcards.
clearSentMessages()functionClear messages this tab sent (same matching rules). Pass sync: true to broadcast the clear to other tabs.
getLatestMessage()functionGet the latest message matching optional filters (type, source). Returns the most recent message that matches, or null if none.
ping(timeoutMs?)functionPing other tabs on the channel and collect their source names. timeoutMs (default: 300ms) controls how long to wait for responses before resolving. Returns a Promise of string array.
isPingInProgressbooleantrue while a ping is active, otherwise false.
closeChannel()functionExplicitly closes the broadcast channel and removes event listeners. Safe to call multiple times.
errorstring | nullAny runtime error from the channel

Clearing examples

// Clear everything we've received
clearReceivedMessages();

// Clear all messages we sent and broadcast the clear to other tabs
clearSentMessages({ sync: true });

// Clear by id or by type (OR inside each array)
clearReceivedMessages({ ids: ['123'] }); // id match
clearReceivedMessages({ types: ['alert', 'chat'] }); // type match

// Combine filters (logical AND between filters)
// Removes messages whose id is '123' AND type is 'alert'
clearSentMessages({ ids: ['123'], types: ['alert'] });

Send Options:

interface SendMessageOptions {
  expirationDuration?: number; // TTL in ms
  expirationDate?: number; // Exact expiry timestamp
}

Message Format:

interface BroadcastMessage {
  id: string;
  type: string;
  message: any;
  timestamp: number;
  source: string;
  expirationDate?: number;
}

Getting the Latest Message

You can use getLatestMessage to retrieve the most recent message received, optionally filtered by type and/or source. If no options are provided, it returns the latest message of any kind. If no message matches, it returns null.

Signature:

getLatestMessage(options?: { type?: string; source?: string }): BroadcastMessage | null

Examples:

// Get the latest message of any type/source
const latest = getLatestMessage();

// Get the latest message of a specific type
const latestAlert = getLatestMessage({ type: 'alert' });

// Get the latest message from a specific source
const latestFromTab = getLatestMessage({ source: 'tab-123' });

// Get the latest message of a specific type from a specific source
const latestInfoFromTab = getLatestMessage({ type: 'info', source: 'tab-123' });

// Check if there are any messages of a type
if (getLatestMessage({ type: 'notification' })) {
  // ...
}

Behavior:

  • If no messages are present, returns null.
  • If no message matches the filter, returns null.
  • If multiple messages match, returns the most recently received one.

Ping & Active Source Detection

useBroadcastChannel provides a ping method and an isPingInProgress state for discovering active sources (tabs) on the same channel.

  • ping(timeoutMs?: number): Promise<string[]>: Broadcasts a ping and collects responses from other tabs within the timeout. Returns an array of source names (excluding your own).
  • isPingInProgress: boolean: Indicates if a ping is currently in progress.

Example:

const { ping, isPingInProgress } = useBroadcastChannel('my-channel', {
  sourceName: 'my-tab',
});

// To discover other active sources:
const activeSources = await ping(300); // e.g., ['tab-2', 'tab-3']

// To show loading state:
if (isPingInProgress) {
  // Show spinner or status
}

Closing the Channel Explicitly

You can use closeChannel to explicitly close the underlying BroadcastChannel and remove all event listeners. This is useful if you want to clean up resources before the component unmounts, or to stop all cross-tab communication on demand. Note that the channel will automatically close and all event listeners will be removed when the component unmounts, so this method is mainly useful for manual cleanup.

Signature:

closeChannel(): void

Example:

const { closeChannel } = useBroadcastChannel('my-channel');

// ... later, when you want to stop all communication:
closeChannel();

Notes:

  • After calling closeChannel, the channel is closed and will not send or receive any more messages.
  • It is safe to call closeChannel multiple times (idempotent).
  • You do not need to call this for normal React unmounting; the hook will clean up automatically. Use it for explicit/manual cleanup only.

onMessage Callbacks

onMessage lets you react to incoming messages imperatively — without polling messages state or using useEffect. The callback fires after the message is added to state and only for messages that pass all active filters (registeredTypes, expiry, deduplication, self-filter). Internal protocol messages (PING, PONG, CLEAR_SENT_MESSAGES) never trigger it.

Two supported shapes:

// 1. A single catch-all function — fires for every accepted message type
type MessageCallback = (msg: BroadcastMessage) => void;

// 2. A map from message type → one handler function
type OnMessageMap = { [type: string]: (msg: BroadcastMessage) => void };

Catch-all example:

const { postMessage } = useBroadcastChannel('my-channel', {
  onMessage: msg => {
    console.log('received', msg.type, msg.message);
  },
});

Per-type map example:

useBroadcastChannel('my-channel', {
  onMessage: {
    error: msg => showToast(`Error: ${msg.message.text}`),
    success: msg => celebrate(),
    log: msg => console.log('[log]', msg.message),
  },
});

Behavior:

  • Fires for messages from other tabs only — self-messages are always ignored before this point.
  • Fires after messages state is updated — both the callback argument and messages[messages.length - 1] will be the same object.
  • A type not listed in the map causes no error — it is silently skipped.
  • If the callback throws, the error is caught and debug-logged. Message state is not affected.
  • Changing onMessage between renders is safe — the latest callback is always used with no stale closure risk.
  • Works with batched messages: each message in a batch triggers its own callback call.

telemetry Option (deprecated)

The package no longer collects any telemetry. The telemetry option is still accepted as a deprecated no-op so existing code keeps compiling; it will be removed in the next major version.


Agent Skill (optional)

The package ships an agent skill at skills/react-broadcast-sync/SKILL.md (the agentskills.io format) with safe recipes for coding agents. Nothing is installed automatically: there is no postinstall script and your agent settings are never modified. To use it, copy it into your project yourself:

mkdir -p .agents/skills
cp -r node_modules/react-broadcast-sync/skills/react-broadcast-sync .agents/skills/

Tools such as skills-npm can also extract it for you. Check your agent's docs for where it reads skills from.

Gotchas

  • Duplicate sourceName: a tab ignores any message whose source equals its own sourceName. If two tabs use the same sourceName, they will not receive each other's messages. Leave it unset (a unique name is generated) unless you need a stable name, and keep it unique per tab.
  • keepLatestMessage is one slot: with keepLatestMessage: true, messages holds only the single most recent message across all types, so a "b" message replaces a prior "a". Use registeredTypes or separate channels/namespaces if you need the latest message per type.
  • cleanupDebounceMs vs cleaningInterval: cleanup is debounced. If cleanupDebounceMs is longer than cleaningInterval, each interval tick restarts the debounce and expired messages may never be removed. Keep cleanupDebounceMs smaller than cleaningInterval.
  • Deduplication is by message ID, not content (see Message Deduplication).
  • Same origin only: BroadcastChannel does not cross origins, and a tab does not receive its own messages.
  • No telemetry: the telemetry option is a deprecated no-op (see Telemetry).

Best Practices

  • Use namespace to isolate functionality between different app modules.
  • Register allowed message types using registeredTypes to avoid processing unknown or irrelevant messages.
  • Always handle error state in UI or logs to detect channel failures.
  • Use keepLatestMessage: true if you only care about the most recent message (e.g. status updates).
  • Set appropriate deduplicationTTL based on your message frequency and importance.
  • Use cleanupDebounceMs when dealing with rapid message updates to prevent performance issues.
  • Use onMessage for imperative side-effects (toasts, analytics, logging) rather than useEffect on messages — it fires exactly once per accepted message, with no extra renders required.

Common Use Cases

Real-time Notifications

function NotificationSystem() {
  const { messages, postMessage } = useBroadcastChannel('notifications', {
    keepLatestMessage: true,
    registeredTypes: ['alert', 'info', 'warning'],
    deduplicationTTL: 60000, // 1 minute
  });

  return (
    <div>
      {messages.map(msg => (
        <Notification key={msg.id} type={msg.type} content={msg.message} />
      ))}
    </div>
  );
}

Multi-tab Form Synchronization

function FormSync() {
  const { messages, postMessage } = useBroadcastChannel('form-sync', {
    namespace: 'my-form',
    cleaningInterval: 5000,
  });

  const handleChange = (field: string, value: string) => {
    postMessage('field-update', { field, value }, { expirationDuration: 300000 }); // 5 minutes
  };

  return <Form onChange={handleChange} />;
}

Tab Status Synchronization

function TabStatus() {
  const { postMessage } = useBroadcastChannel('tab-status', {
    sourceName: 'main-tab',
    keepLatestMessage: true,
  });

  useEffect(() => {
    postMessage('tab-active', { timestamp: Date.now() });
    return () => postMessage('tab-inactive', { timestamp: Date.now() });
  }, []);

  return null;
}

Performance Considerations

Message Size

  • Keep messages small and serializable
  • Avoid sending large objects or circular references
  • Consider using message IDs to reference larger data

Message Frequency

  • Use keepLatestMessage: true for high-frequency updates
  • Implement debouncing for rapid state changes
  • Consider using expirationDuration for temporary messages

Batching Mechanism

Batching allows you to group multiple outgoing messages and send them together in a single post to the BroadcastChannel. This can significantly reduce the number of cross-tab events, improve performance, and avoid flooding the channel when many messages are sent in rapid succession (e.g., during fast typing or bulk updates).

  • batchingDelayMs: If set to a value greater than 0 (default: 20ms), outgoing messages are collected for up to this delay and then sent as an array. If 0 or negative, batching is disabled and all messages are sent immediately.
  • excludedBatchMessageTypes: An array of message types that should always be sent immediately, even if batching is enabled. Use this for urgent or high-priority messages (e.g., 'alert', 'sync-now').

How it works:

  • When batching is enabled, calls to postMessage within the batching window are buffered and sent as a batch (array of messages) after the delay.
  • On the receiving side, the hook automatically handles both single messages and batches (arrays). If you listen to the channel directly, always check if Array.isArray(event.data).
  • If the tab unmounts or the channel closes, any unsent batched messages are flushed immediately.

Why batching matters:

  • Reduces the number of events and improves efficiency, especially for high-frequency updates.
  • Prevents message storms that can occur when many updates happen in a short time.
  • Lets you control which messages are always sent immediately for real-time needs.

Example:

const { postMessage } = useBroadcastChannel('my-channel', {
  batchingDelayMs: 50, // Batch messages for up to 50ms
  excludedBatchMessageTypes: ['alert'], // Always send 'alert' immediately
});

// These will be batched if sent within 50ms
postMessage('edit', { field: 'a', value: 1 });
postMessage('edit', { field: 'b', value: 2 });
// This will be sent immediately
postMessage('alert', { message: 'Something happened!' });

Memory Management

  • Clear messages when they're no longer needed using clearReceivedMessages / clearSentMessages
  • Use cleaningInterval to automatically remove expired messages
  • Implement proper cleanup in component unmount

Message Deduplication

The deduplicationTTL option creates a time window (in milliseconds) during which a received message with an already-seen message ID is ignored. Deduplication is by ID, not by content: two separate postMessage calls with identical type and payload get different IDs and are both delivered. This is particularly useful for:

  • Preventing Message Loops: Avoids infinite message echo between tabs when they broadcast the same message back and forth
  • Reducing Redundancy: Drops the same message if it is delivered to a tab more than once within the window

Recommended TTL values based on use case:

  • High-frequency updates (e.g., real-time typing, cursor position): 1000-5000ms
  • Medium-frequency updates (e.g., form sync, status changes): 5000-15000ms
  • Low-frequency updates (e.g., notifications, alerts): 15000-30000ms

Example:

// Without deduplication, this could cause a message loop
function ChatComponent() {
  const { postMessage } = useBroadcastChannel('chat', {
    deduplicationTTL: 5000, // Ignore duplicate messages for 5 seconds
  });

  const handleMessage = (text: string) => {
    postMessage('chat-message', { text });
  };
}

Cleanup Optimization

  • Use cleanupDebounceMs to prevent excessive cleanup operations
  • Recommended values:
    • For frequent updates: 500-1000ms
    • For infrequent updates: 0ms (no debounce)
  • Adjust cleaningInterval based on your message expiration needs

Troubleshooting

Common Issues

  1. Messages Not Received

    • Check if registeredTypes includes your message type
    • Verify the channel name and namespace match
    • Ensure the message hasn't expired
    • Check if deduplicationTTL isn't too short
  2. Performance Issues

    • Increase cleanupDebounceMs if cleanup is too frequent
    • Use keepLatestMessage: true for high-frequency updates
    • Consider increasing cleaningInterval if cleanup is too aggressive
  3. Memory Leaks

    • Ensure proper cleanup in component unmount
    • Use message expiration for temporary data
    • Clear messages when they're no longer needed

Debug Mode

Enable debug logging by setting the environment variable:

REACT_APP_DEBUG_BROADCAST=true

This will log:

  • Channel creation and closure
  • Message sending and receiving
  • Cleanup operations
  • Error states

Testing

Unit Testing

import { renderHook, act } from '@testing-library/react-hooks';
import { useBroadcastChannel } from 'react-broadcast-sync';

test('postMessage records the message in sentMessages', () => {
  const { result } = renderHook(() => useBroadcastChannel('test-channel'));

  act(() => {
    result.current.postMessage('test', { data: 'hello' });
  });

  // The hook does not echo your own messages back into `messages`.
  // `messages` only holds messages sent by other tabs or hook instances.
  expect(result.current.messages).toHaveLength(0);
  expect(result.current.sentMessages).toHaveLength(1);
  expect(result.current.sentMessages[0].message.data).toBe('hello');
});

To test receiving, render two hooks with different sourceName values on the same channel name (this needs a BroadcastChannel implementation in the test environment, such as a mock that delivers to other instances). The message sent by one appears in the other's messages.

Integration Testing

import { render, screen } from '@testing-library/react';
import { BroadcastProvider } from 'react-broadcast-sync';

test('should render messages from provider', () => {
  render(
    <BroadcastProvider channelName="test-channel">
      <TestComponent />
    </BroadcastProvider>
  );

  // Your test assertions here
});

Running This Repository's Test Suites

The repository has two test suites:

# Unit tests (Jest + jsdom)
npm test

# Cross-tab integration tests (Playwright + Chromium, Firefox, and WebKit)
npx playwright install --with-deps chromium firefox webkit   # one-time browser install
npm run test:integration

The integration suite runs each test in Chromium, Firefox, and WebKit. It serves a small React app from integration/app and drives real browser tabs through the public hook API: message delivery between tabs, self-filtering, namespace isolation, registeredTypes filtering, ping source discovery, synced clearSentMessages, local clearReceivedMessages, and message expiration.

Browser Support

Relies on BroadcastChannel API:

  • ✅ Chrome 54+
  • ✅ Firefox 38+
  • ✅ Edge 79+
  • ✅ Safari 15.4+
  • ✅ Opera 41+

Telemetry

The package no longer collects any telemetry. The telemetry option is still accepted as a deprecated no-op so existing code keeps compiling; it will be removed in the next major version.


Coming Soon

We're actively improving react-broadcast-sync! Here are some features and enhancements planned for upcoming versions:

  • Automatic Channel Recovery
    Reconnect automatically if the BroadcastChannel gets disconnected or closed by the browser, with configurable retry delay and attempt cap.

  • Cross-tab History (withHistory)
    On connect, a new tab can request recent message history from already-open tabs. Includes chunked transfer, expiry filtering, and deduplication to prevent replaying stale or duplicate messages.

  • Integration Tests
    Real browser cross-tab tests using Playwright that go beyond what jsdom mocks can cover.

  • Anonymous Usage Telemetry
    Removed. The telemetry option remains as a deprecated no-op.

We're committed to keeping this package lightweight, flexible, and production-ready.
Your feedback and contributions are welcome — feel free to open an issue!


Versioning & Releases

This project uses Semantic Release for fully automated versioning and changelog generation.

Every push to the main branch with a Conventional Commit message triggers a release that includes:

  • ✅ Automatic semantic version bump (major, minor, or patch)
  • ✅ Changelog generation and publishing to GitHub Releases
  • ✅ Publishing to npm

Example Commit Messages

feat: add support for per-type callbacks
fix: debounce cleanup runs properly on tab reload
chore: update dependencies

---

## Contributing

PRs and feature suggestions welcome! Open an issue or submit a pull request.

---

## License

MIT © [Idan Shalem](https://github.com/IdanShalem)

Project health

Latest release

2.1.3 · Oct 8, 2026

Releases this year

11