DEV Community

Cover image for Using react-broadcast-sync in a real app: dashboard filters across tabs
Idan Shalem
Idan Shalem

Posted on

Using react-broadcast-sync in a real app: dashboard filters across tabs

In Part 1 we found the problem: React apps quietly drift apart across browser tabs. In Part 2 we built the abstraction. Now let's actually use it.

Picture a dashboard open in two tabs. You change the status filter in one, switch to the other, and it still shows the old view. The data comes from the same API, but each tab holds its own copy of the UI state. Let's fix that with react-broadcast-sync: install, one real example, the gotchas I hit, and the few options worth knowing. The full options reference lives in the README, so I won't repeat it here.

1. Installing react-broadcast-sync

npm install react-broadcast-sync
Enter fullscreen mode Exit fullscreen mode

Usage telemetry is off unless you set telemetry: true. Sync works the same either way.

There are two ways in. useBroadcastChannel(channelName, options) is a hook for one component that needs its own channel. BroadcastProvider calls the same hook once and shares the result through context, so any descendant can read it with useBroadcastProvider(). I use the provider near the root for a shared app channel.

import { BroadcastProvider } from 'react-broadcast-sync';
import Dashboard from './Dashboard';

export default function App() {
  return (
    <BroadcastProvider
      channelName="dashboard"
    >
      <Dashboard />
    </BroadcastProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Dashboard is your own component. Inside it, postMessage(type, payload) sends, and incoming payloads arrive as message.message. Open the app in two tabs on the same origin and you're connected.

2. A real-world pattern: dashboard filters

The dashboard has two controls, item status and date range. A change in one tab should update the other. I send the complete selection each time, so the receiver applies one object instead of rebuilding it from field changes.

I keep the filter shape, defaults and message name in one place:

export type Filters = {
  status: 'all' | 'open' | 'closed';
  range: '7d' | '30d';
};

export const initialFilters: Filters = { status: 'all', range: '7d' };
export const FILTERS_APPLY = 'filters/apply';
Enter fullscreen mode Exit fullscreen mode

The library's postMessage takes a string type and a payload. It doesn't infer a typed protocol, and TypeScript types don't validate incoming data at runtime, so the receiver checks the values itself.

Here's the whole feature in App.tsx. The channel is dashboard with the namespace filters-v1, joined as dashboard-filters-v1. Every participating tab uses the same pair.

import { useState } from 'react';
import { BroadcastProvider, useBroadcastProvider } from 'react-broadcast-sync';
import { FILTERS_APPLY, initialFilters } from './filters';
import type { Filters } from './filters';

export default function App() {
  const [filters, setFilters] = useState<Filters>(initialFilters);

  return (
    <BroadcastProvider
      channelName="dashboard"
      options={{
        namespace: 'filters-v1',
        registeredTypes: [FILTERS_APPLY],
        onMessage: {
          [FILTERS_APPLY]: message => {
            const next = message.message;
            if (
              next &&
              (next.status === 'all' || next.status === 'open' ||
                next.status === 'closed') &&
              (next.range === '7d' || next.range === '30d')
            ) {
              setFilters({ status: next.status, range: next.range });
            }
          },
        },
      }}
    >
      <FilterPanel filters={filters} onApply={setFilters} />
    </BroadcastProvider>
  );
}

function FilterPanel({ filters, onApply }: {
  filters: Filters;
  onApply: (next: Filters) => void;
}) {
  const { postMessage, error } = useBroadcastProvider();

  function applyFilters(next: Filters) {
    onApply(next);
    postMessage(FILTERS_APPLY, next);
  }

  return (
    <section>
      <h1>Dashboard filters</h1>
      <label>
        Status
        <select value={filters.status} onChange={event => {
          const status = event.target.value;
          if (status === 'all' || status === 'open' || status === 'closed') {
            applyFilters({ ...filters, status });
          }
        }}>
          <option value="all">All</option>
          <option value="open">Open</option>
          <option value="closed">Closed</option>
        </select>
      </label>
      <label>
        Date range
        <select value={filters.range} onChange={event => {
          const range = event.target.value;
          if (range === '7d' || range === '30d') {
            applyFilters({ ...filters, range });
          }
        }}>
          <option value="7d">Last 7 days</option>
          <option value="30d">Last 30 days</option>
        </select>
      </label>
      <p>Showing {filters.status} items for {filters.range}.</p>
      {error && <p role="alert">{error}</p>}
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

The flow has two entry points. A user changes a control: applyFilters updates this tab, then sends the selection. Another tab sends a selection: onMessage validates it, then updates this tab. I broadcast in the user action and keep the incoming handler focused on applying state, so a received change never gets re-sent.

Tab A: user changes a filter
   |  applyFilters: update local state, then postMessage('filters/apply', next)
   v
BroadcastChannel 'dashboard-filters-v1'
   |
   v
Tab B: onMessage validates the payload, then setFilters(next)
Enter fullscreen mode Exit fullscreen mode

What travels over the channel is a snapshot of UI state: the complete filter selection, not a stream of field edits.

In a real dashboard, filters would also feed your query or chart props. The example stops at the UI so the sync code stays visible.

Filters are rarely the only thing a dashboard syncs. Once a second feature needs to talk across tabs, you have to decide how the two share a channel.

Channel, namespace, registeredTypes

When a second feature shows up, say a theme toggle, you have three tools, and they do different jobs. The channel name plus namespace decides which tabs and hooks talk to each other: the resolved BroadcastChannel name is channelName-namespace, so dashboard with filters-v1 becomes dashboard-filters-v1. registeredTypes only filters on receive: unlisted types are dropped after they arrive.

If the types belong to one feature and the same components, keep one channel and set registeredTypes. If the features are independent, with different listeners or options, give each its own channel or namespace. With one resolved name and a hook per feature, the browser still delivers every message to both connections and the ignored ones are dropped in JavaScript. Separate channels never deliver them at all.

3. Gotchas

The browser observations below came from testing in two Chrome tabs under React StrictMode. They describe the tested setup, not every browser or app.

Broadcasting is not persistence

A tab opened later gets nothing sent before it connected. In my test a third tab started with the app defaults. Load the current selection from your normal data source.

Apply locally, then broadcast once

The sending hook doesn't get its own message back, so update local state first. Never re-broadcast what you just received. Leave sourceName generated: two tabs with the same name ignored each other in my test.

Delivery order is not a conflict policy

Two near-simultaneous edits can leave tabs with different last values. For business records the server stays authoritative.

Expiry is not app state

An expired notice vanishes from the receiver's messages, but it doesn't undo state your onMessage already set, and it stays in the sender's list. expirationDuration: 0 means no expiry.

Routing is not security

A namespace and registeredTypes pick which messages arrive. They don't validate payloads or authenticate senders. Keep the runtime checks, and never treat a channel message as authorization for a server action.

4. The options worth knowing

The README lists every option with its default. These are the ones this dashboard actually needed:

  • namespace: appended to the channel name (channelName-namespace) to separate features or protocol versions. Use the same pair in every tab.
  • registeredTypes: drops unlisted incoming types before they reach messages or onMessage. It filters on receive only. I set it on every feature channel.
  • onMessage: handles accepted incoming messages, as one function or a map keyed by type. It receives the envelope. React may not have rendered the new messages yet, so use the argument.
  • keepLatestMessage: one slot across all types, so a message of another type replaces the previous one. Good for a single latest-value stream like a theme, wrong for filters plus notices in one provider. For latest-per-type, use registeredTypes or separate channels.
  • telemetry: off by default. Omit it to keep usage events off; set telemetry: true only to opt in. Sync works either way.
  • expirationDuration on postMessage: for short-lived notices. Use a positive number of milliseconds.

The timing options (cleaningInterval, cleanupDebounceMs, deduplicationTTL, batchingDelayMs, excludedBatchMessageTypes) can wait until you have a concrete reason. Defaults were fine for me. One thing to know if you touch them: a positive cleanupDebounceMs longer than cleaningInterval can postpone cleanup indefinitely.

Recipe: tell other tabs to refresh

This is a pattern, not a feature of the library. After saving a record, send its ID as a hint. The receiver validates it and refetches through your data layer. The channel carries the hint and the server supplies the truth.

const { postMessage } = useBroadcastChannel('records', {
  registeredTypes: ['record/invalidated'],
  onMessage: {
    'record/invalidated': message => {
      const value = message.message;
      if (value && typeof value.id === 'string') {
        // Call your app's refetch/invalidation function here.
      }
    },
  },
});
// In your save handler, after the existing request succeeds:
postMessage('record/invalidated', { id: 'record-42' });
Enter fullscreen mode Exit fullscreen mode

If you use TanStack Query, the comment becomes one line: queryClient.invalidateQueries({ queryKey: ['records', value.id] }).

Bonus: if a coding agent writes your code

The package ships a SKILL.md for coding agents (the agentskills.io format) and an llms.txt generated from the README. Nothing is installed automatically. If you use an agent, copy the skill into your project yourself and check where your agent reads skills from:

mkdir -p .agents/skills
cp -r node_modules/react-broadcast-sync/skills/react-broadcast-sync .agents/skills/
Enter fullscreen mode Exit fullscreen mode

The README has an Agent Skill section with the details. It also helps to point your agent at the README and this post, since the gotchas above are the mistakes agents make most.

Wrapping up

We started with tabs that each lived in their own bubble. The fix turned out small: local React state for the UI, a channel that carries change notifications, a provider for a shared subtree, and separate channels when features stop sharing a protocol. Validate incoming values, update locally before sending, and decide up front how new tabs and conflicting edits should behave instead of assuming the channel handles them.

That closes the series: the problem, the abstraction, and now the use. Your tabs don't have to be isolated anymore. Try it on one filter bar, and tell me which tabs were open, what you sent and what you expected the other tab to do. The full option reference is in the README.

Top comments (1)

Collapse
 
idanshalem profile image
Idan Shalem •

If you hit a case this post doesn't cover (late-opened tabs, SSR, lots of channels), tell me what you tried and what you expected. I'll turn it into a recipe in the repo.