DEV Community

Idan Shalem
Idan Shalem

Posted on

Building React Multi-Tab Sync: A Custom Hook with the BroadcastChannel API

Ever opened two tabs of your React app and watched them slowly drift out of sync? Maybe a logout in one tab doesn’t register in the other. Or a filter change in one view disappears in the second. It's not a bug in your code - it's a blind spot in how most apps handle state.

In Part 1 of this series, I broke down why this happens - and how the browser's native BroadcastChannel API offers a solid solution for syncing tabs without a backend.

But actually using BroadcastChannel in a React app? That’s where things get tricky. My early attempts were simple - a quick useEffect here, an event listener there - but they unraveled fast. Lingering listeners, duplicate actions, messy message state. It quickly became clear: a reliable abstraction needed more thought.

This post walks through the journey of building react-broadcast-sync, a custom hook designed to make multi-tab communication seamless in React. From message deduplication to smart batching and lifecycle management, here’s everything I had to solve - and how I did it.


Table of Contents


The Initial Approach: Learning the Limitations

Like many devs, my first instinct was to toss BroadcastChannel into a useEffect and call it a day.

import { useEffect, useRef } from 'react';

function useInitialBroadcastChannel(channelName: string) {
  const channelRef = useRef<BroadcastChannel | null>(null);

  useEffect(() => {
    const channel = new BroadcastChannel(channelName);
    channelRef.current = channel;

    channel.onmessage = event => {
      console.log('Received:', event.data);
      // Problem: If the component unmounts, we’ve got a ghost listener hanging around.
    };

    return () => {
      // Uh-oh: If we forget to close the channel, we risk memory leaks.
      channel.close();
    };
  }, [channelName]);

  const postMessage = (data: any) => {
    channelRef.current?.postMessage(data);
  };

  return { postMessage };
}
Enter fullscreen mode Exit fullscreen mode

This worked fine for basic tests - until I tried it in a real app. That’s when the cracks started to show.

Two major issues popped up:

  • Leaky listeners: Channels weren’t always cleaned up on unmount.
  • Stale state: React callbacks often referenced outdated data.

It became clear that I needed a more React-friendly abstraction - one that understood lifecycles, state, and isolation.


Redundant Processing: A Distributed System Headache

When you're syncing tabs, you're essentially building a distributed system. And one annoying problem pops up fast: double-processing.

Example: Tab A logs the user out and broadcasts it. Tab B receives the message and logs out too. But what if Tab A also reacts to that broadcast? You get a double logout - or worse, an error.

Even though BroadcastChannel doesn’t echo messages back to the sender, your logic might still be wired to react to all incoming events - including your own.

The Fix: Message IDs and Source IDs

To make smart decisions, each message gets:

  • A unique id
  • A source ID identifying the tab that sent it

Now, tabs can ignore messages from themselves and skip anything they’ve already seen.

channel.onmessage = event => {
  const { id, type, source } = event.data;

  if (source === myTabId) return; // ignore my own messages
  if (isRecentlyProcessed(id)) return; // skip duplicates

  if (type === 'logout') {
    logout();
  }
};
Enter fullscreen mode Exit fullscreen mode

With this, actions like logout or sync are processed once and only once per tab - even under race conditions.


Managing Message Lifecycles: Expiration and Cleanup

Some messages - like “user is typing…” or “temporary alert” - shouldn’t live forever. But by default, BroadcastChannel messages aren’t stored at all, so I was persisting them in state. The problem? That state grew forever.

The Fix: Expiration + Cleanup Interval

Each message can now include an expirationDuration. Behind the scenes, a cleanup timer runs periodically to remove expired messages from state.

postMessage('notification', { text: 'You’re connected!' }, { expirationDuration: 5000 });
Enter fullscreen mode Exit fullscreen mode

The hook checks and removes stale messages every few seconds, keeping your app lean and snappy.


Optimizing for Volume: Batching High Frequency Messages

When syncing typing data or drag events, I noticed major UI jank. Turns out, spamming postMessage() dozens of times per second is a bad idea.

The Fix: Batching with Optional Bypass

I added a batchingDelayMs config. Instead of sending each message immediately, the hook groups them within a short window, then sends them as a batch. You can also exclude specific types from batching.

const { postMessage } = useBroadcastChannel('typing', {
  batchingDelayMs: 50,
  excludedBatchMessageTypes: ['form_submit'],
});
Enter fullscreen mode Exit fullscreen mode

Now, typing updates stay smooth - and critical actions still go through instantly.


Tracking Who Sent What: The Source ID

The source field turned out to be more than just a way to skip my own messages. It became a powerful way to attribute updates and target logic.

Examples:

  • Show which tab sent the latest filter change
  • Detect if a message came from a different device
  • Ping active tabs and get their IDs
const latest = getLatestMessage({ source: 'tab-abc-123', type: 'theme_change' });
Enter fullscreen mode Exit fullscreen mode

Every message now has traceability - great for debugging or building smarter features.


Focusing on Current State: Keeping Only the Latest Message

Sometimes, history doesn’t matter - only the latest state does.

If your component only cares about the current dashboard filters or online status, why store 50 updates?

The Fix: keepLatestMessage Mode

This mode tells the hook: "Only keep the newest message of each type." Cleaner state, simpler logic.

const { getLatestMessage } = useBroadcastChannel('filters', {
  keepLatestMessage: true,
});
Enter fullscreen mode Exit fullscreen mode

You still get getLatestMessage() for querying, and you avoid managing long message arrays.


Tuning In: Filtering by Message Type

As the app grew, channels got busier - carrying everything from user presence to theme toggles. But not every component cared about every message.

The Fix: registeredTypes

Specify which message types you want. The hook silently ignores the rest.

const { messages } = useBroadcastChannel('app-state', {
  registeredTypes: ['theme_change', 'notification'],
});
Enter fullscreen mode Exit fullscreen mode

Less noise, fewer re-renders, cleaner code.


Hands On Control: Utility Methods for Messages

While automatic cleanup is great, sometimes you need manual control - like clearing a specific notification or resetting state.

Exposed Utilities:

  • getLatestMessage() - grab the newest message by filter
  • clearReceivedMessages() - remove specific messages
  • clearSentMessages({ sync: true }) - delete sent messages and notify others
  • clearAllReceivedMessages() - wipe it clean
clearSentMessages({ types: ['notification'], sync: true });
Enter fullscreen mode Exit fullscreen mode

Powerful tools, available when you need them.


Simplifying Integration: BroadcastProvider

Managing channels manually across components got tedious fast. I was duplicating hook logic and prop-drilling channel methods.

The Fix: React Context

With <BroadcastProvider>, you define a shared channel once and access it from anywhere.

<BroadcastProvider channelName="global">
  <App />
</BroadcastProvider>
Enter fullscreen mode Exit fullscreen mode

Then in any nested component:

const { postMessage, getLatestMessage } = useBroadcastProvider();
Enter fullscreen mode Exit fullscreen mode

Clean, centralized, and idiomatic.


Making It Bulletproof: Thorough Testing

To make this robust, I built a full test suite using a mocked BroadcastChannel. It simulates tabs, message delivery, cleanup timing, deduplication, batching - the whole thing.

Each feature was tested in isolation and under stress to ensure consistent behavior across tabs and edge cases.

If you're curious, the repo has full test coverage and examples you can learn from or extend.


Real World Use Cases

These features are now powering real multi-tab sync scenarios:

  • Counters and input fields synced live across tabs
  • Theme changes reflected instantly everywhere
  • Logout from one tab propagates to all others
  • Filter syncing across dashboards
  • Shared draft editing in real-time

It’s fast, simple to integrate, and battle-tested.


What’s Next

This post was all about the engineering behind the hook. In the final post of the series, I’ll walk through:

  • Installing react-broadcast-sync
  • Real-world usage patterns
  • Demos and gotchas
  • Advanced config and recipes

Live demo: react-broadcast-sync.vercel.app
npm: react-broadcast-sync
GitHub: IdanShalem/react-broadcast-sync

Stay tuned - and if you’ve got ideas, questions, or cool use cases, I’d love to hear them!

Top comments (0)