One function call. Every tab in sync. Zero dependencies.
Getting Started · API · React · Zustand · Jotai · Redux · DevTools · Next.js · Architecture · Examples · Live Demo
When users open your app in multiple tabs, things break — stale data, duplicated WebSocket connections, conflicting writes.
tab-bridge solves all of this with a single function call:
const sync = createTabSync({ initial: { theme: 'light', count: 0 } });Every tab now shares the same state. One tab is automatically elected as leader. You can call functions across tabs like they're local. No server needed.
|
LWW conflict resolution with batched broadcasts and custom merge strategies Bully algorithm with heartbeat monitoring and automatic failover Fully typed arguments, Promise-based calls with
|
7 hooks built on Intercept, validate, and transform state changes before they're applied Survive page reloads with key whitelisting and custom storage backends First-class integrations — Floating Native browser APIs only, ~4KB gzipped, fully tree-shakable |
npm install tab-bridgeimport { createTabSync } from 'tab-bridge';
const sync = createTabSync({
initial: { theme: 'light', count: 0 },
});
// Read & write — synced to all tabs instantly
sync.get('theme'); // 'light'
sync.set('theme', 'dark'); // → every tab updates
// Subscribe to changes
const off = sync.on('count', (value, meta) => {
console.log(`count is now ${value} (${meta.isLocal ? 'local' : 'remote'})`);
});
// Leader election — automatic
sync.onLeader(() => {
const ws = new WebSocket('wss://api.example.com');
return () => ws.close(); // cleanup when leadership is lost
});
// Cross-tab RPC
sync.handle('double', (n: number) => n * 2);
const result = await sync.call('leader', 'double', 21); // 42The single entry point. Returns a fully-typed TabSyncInstance.
const sync = createTabSync<MyState>({
initial: { theme: 'light', count: 0 },
channel: 'my-app',
debug: true,
});📋 Full Options Table
| Option | Type | Default | Description |
|---|---|---|---|
initial |
TState |
{} |
Initial state before first sync |
channel |
string |
'tab-sync' |
Channel name — only matching tabs communicate |
transport |
'broadcast-channel' | 'local-storage' |
auto | Force a specific transport layer |
merge |
(local, remote, key) => value |
LWW | Custom conflict resolution |
leader |
boolean | LeaderOptions |
true |
Leader election config |
debug |
boolean |
false |
Enable colored console logging |
persist |
PersistOptions | boolean |
false |
State persistence config |
middlewares |
Middleware[] |
[] |
Middleware pipeline |
onError |
(error: Error) => void |
noop | Global error callback |
📊 State
sync.get('theme') // Read single key
sync.getAll() // Read full state (stable reference)
sync.set('theme', 'dark') // Write single key → broadcasts to all tabs
sync.patch({ theme: 'dark', count: 5 }) // Write multiple keys in one broadcast
// Atomic multi-key update — return null to abort
sync.transaction((state) => {
if (state.count >= 100) return null; // abort
return { count: state.count + 1, lastUpdated: Date.now() };
});🔔 Subscriptions
const off = sync.on('count', (value, meta) => { /* ... */ });
off(); // unsubscribe
sync.once('theme', (value) => console.log('Theme changed:', value));
sync.onChange((state, changedKeys, meta) => { /* ... */ });
sync.select(
(state) => state.items.filter(i => i.done).length,
(doneCount) => updateBadge(doneCount),
);
// Debounced derived state — callback fires at most once per 200ms
sync.select(
(state) => state.items.length,
(count) => analytics.track('item_count', count),
{ debounce: 200 },
);👑 Leader Election
sync.isLeader() // → boolean
sync.getLeader() // → TabInfo | null
sync.onLeader(() => {
const ws = new WebSocket('wss://...');
return () => ws.close(); // Cleanup on resign
});
const leader = await sync.waitForLeader(); // Promise-based📋 Tab Registry
sync.id // This tab's UUID
sync.getTabs() // → TabInfo[]
sync.getTabCount() // → number
sync.onTabChange((tabs) => {
console.log(`${tabs.length} tabs open`);
});📡 Cross-Tab RPC
sync.handle('getServerTime', () => ({
iso: new Date().toISOString(),
}));
const { iso } = await sync.call('leader', 'getServerTime');
const result = await sync.call(tabId, 'compute', payload, 10_000);
// Broadcast RPC to ALL other tabs and collect responses
const results = await sync.callAll('getStatus');
// results: Array<{ tabId: string; result?: T; error?: string }>♻️ Lifecycle
sync.ready // false after destroy
sync.destroy() // graceful shutdown, safe to call multiple timesDefine an RPC contract and get full end-to-end type inference — arguments, return types, and method names are all checked at compile time:
interface MyRPC {
getTime: { args: void; result: { iso: string } };
add: { args: { a: number; b: number }; result: number };
search: { args: string; result: string[] };
}
const sync = createTabSync<MyState, MyRPC>({
initial: { count: 0 },
});
sync.handle('add', ({ a, b }) => a + b); // args are typed
const { iso } = await sync.call('leader', 'getTime'); // result is typed
const results = await sync.call(tabId, 'search', 'query'); // string[]Intercept, validate, and transform state changes before they're applied:
const sync = createTabSync({
initial: { name: '', age: 0 },
middlewares: [
{
name: 'validator',
onSet({ key, value, previousValue, meta }) {
if (key === 'age' && (value as number) < 0) return false; // reject
if (key === 'name') return { value: String(value).trim() }; // transform
},
afterChange(key, value, meta) {
analytics.track('state_change', { key, source: meta.sourceTabId });
},
onDestroy() { /* cleanup */ },
},
],
});State survives page reloads automatically:
// Simple — persist everything to localStorage
createTabSync({ initial: { ... }, persist: true });
// Advanced — fine-grained control
createTabSync({
initial: { theme: 'light', tempData: null },
persist: {
key: 'my-app:state',
include: ['theme'], // only persist these keys
debounce: 200, // debounce writes (ms)
storage: sessionStorage, // custom storage backend
},
});First-class React integration built on useSyncExternalStore for zero-tear concurrent rendering.
import {
TabSyncProvider, useTabSync, useTabSyncValue, useTabSyncSelector,
useIsLeader, useTabs, useLeaderInfo, useTabSyncActions,
} from 'tab-bridge/react';TabSyncProvider — Context Provider
<TabSyncProvider options={{ initial: { count: 0 }, channel: 'app' }}>
<App />
</TabSyncProvider>useTabSync() — All-in-one hook
function Counter() {
const { state, set, isLeader, tabs } = useTabSync<MyState>();
return (
<div>
<h2>Count: {state.count}</h2>
<button onClick={() => set('count', state.count + 1)}>+1</button>
<p>{isLeader ? '👑 Leader' : 'Follower'} · {tabs.length} tabs</p>
</div>
);
}useTabSyncValue(key) — Single key, minimal re-renders
function ThemeDisplay() {
const theme = useTabSyncValue<MyState, 'theme'>('theme');
return <div className={`app ${theme}`}>Current theme: {theme}</div>;
}useTabSyncSelector(selector) — Derived state with memoization
function DoneCount() {
const count = useTabSyncSelector<MyState, number>(
(state) => state.todos.filter(t => t.done).length,
);
return <span className="badge">{count} done</span>;
}useIsLeader() — Leadership status
function LeaderIndicator() {
const isLeader = useIsLeader();
if (!isLeader) return null;
return <span className="badge badge-leader">Leader Tab</span>;
}useTabs() — Active tab list
function TabList() {
const tabs = useTabs();
return <p>{tabs.length} tab(s) open</p>;
}useLeaderInfo() — Leader tab info
function LeaderDisplay() {
const leader = useLeaderInfo();
if (!leader) return <p>No leader yet</p>;
return <p>Leader: {leader.id}</p>;
}useTabSyncActions() — Write-only (no re-renders)
function IncrementButton() {
const { set, patch, transaction } = useTabSyncActions<MyState>();
return <button onClick={() => set('count', prev => prev + 1)}>+1</button>;
}Components using only useTabSyncActions never re-render due to state changes — perfect for write-only controls.
One-line integration for Zustand stores — all tabs stay in sync automatically.
npm install zustandimport { create } from 'zustand';
import { tabSync } from 'tab-bridge/zustand';
const useStore = create(
tabSync(
(set) => ({
count: 0,
theme: 'light',
inc: () => set((s) => ({ count: s.count + 1 })),
setTheme: (t: string) => set({ theme: t }),
}),
{ channel: 'my-app' }
)
);
// That's it — all tabs now share the same state.
// Functions (actions) are never synced, only data.📋 Middleware Options
| Option | Type | Default | Description |
|---|---|---|---|
channel |
string |
'tab-sync-zustand' |
Channel name for cross-tab communication |
include |
string[] |
— | Only sync these keys (mutually exclusive with exclude) |
exclude |
string[] |
— | Exclude these keys from syncing (mutually exclusive with include) |
merge |
(local, remote, key) => value |
LWW | Custom conflict resolution |
transport |
'broadcast-channel' | 'local-storage' |
auto | Force a specific transport |
debug |
boolean |
false |
Enable debug logging |
onError |
(error) => void |
— | Error callback |
onSyncReady |
(instance) => void |
— | Access the underlying TabSyncInstance for RPC/leader features |
🔑 Selective Key Sync
const useStore = create(
tabSync(
(set) => ({
count: 0,
theme: 'light',
localDraft: '', // won't be synced
inc: () => set((s) => ({ count: s.count + 1 })),
}),
{
channel: 'my-app',
exclude: ['localDraft'], // keep this key local-only
}
)
);🤝 Works with Zustand persist
Compose with Zustand's persist middleware — order doesn't matter:
import { persist } from 'zustand/middleware';
const useStore = create(
persist(
tabSync(
(set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
}),
{ channel: 'my-app' }
),
{ name: 'my-store' }
)
);🚀 Advanced: Access tab-bridge Instance
Use onSyncReady to access the underlying TabSyncInstance for RPC, leader election, and other advanced features:
let syncInstance: TabSyncInstance | null = null;
const useStore = create(
tabSync(
(set) => ({ count: 0 }),
{
channel: 'my-app',
onSyncReady: (instance) => {
syncInstance = instance;
instance.handle('getCount', () => useStore.getState().count);
instance.onLeader(() => {
console.log('This tab is now the leader');
return () => console.log('Leadership lost');
});
},
}
)
);Synchronize individual Jotai atoms across browser tabs with zero boilerplate.
npm install tab-bridge jotaiimport { atomWithTabSync } from 'tab-bridge/jotai';
const countAtom = atomWithTabSync('count', 0, { channel: 'my-app' });
const themeAtom = atomWithTabSync('theme', 'light', { channel: 'my-app' });Use them like regular atoms — useAtom(countAtom) works as expected. Changes are automatically synced to all tabs.
| Option | Type | Default | Description |
|---|---|---|---|
channel |
string |
'tab-sync-jotai' |
Channel name for cross-tab communication |
transport |
'broadcast-channel' | 'local-storage' |
auto-detect | Force a specific transport |
debug |
boolean |
false |
Enable debug logging |
onError |
(error: Error) => void |
— | Error callback |
onSyncReady |
(instance: TabSyncInstance) => void |
— | Access underlying instance |
Each atom creates its own createTabSync instance scoped to ${channel}:${key}. The instance is created when the atom is first subscribed to and destroyed when the last subscriber unmounts.
Derived atoms work out of the box:
import { atom } from 'jotai';
const doubledAtom = atom((get) => get(countAtom) * 2);
// Automatically updates when countAtom syncs from another tabSynchronize your Redux (or Redux Toolkit) store across browser tabs via a store enhancer.
npm install tab-bridge redux # or @reduxjs/toolkitimport { configureStore } from '@reduxjs/toolkit';
import { tabSyncEnhancer } from 'tab-bridge/redux';
const store = configureStore({
reducer: { counter: counterReducer, theme: themeReducer },
enhancers: (getDefault) =>
getDefault().concat(tabSyncEnhancer({ channel: 'my-app' })),
});Every dispatch that changes state is automatically synced. Remote changes are merged via an internal @@tab-bridge/MERGE action — no reducer changes needed.
| Option | Type | Default | Description |
|---|---|---|---|
channel |
string |
'tab-sync-redux' |
Channel name |
include |
string[] |
— | Only sync these top-level keys (slice names) |
exclude |
string[] |
— | Exclude these top-level keys |
merge |
(local, remote, key) => unknown |
LWW | Custom conflict resolution |
transport |
'broadcast-channel' | 'local-storage' |
auto-detect | Force transport |
debug |
boolean |
false |
Debug logging |
onError |
(error: Error) => void |
— | Error callback |
onSyncReady |
(instance: TabSyncInstance) => void |
— | Access underlying instance |
tabSyncEnhancer({
channel: 'my-app',
include: ['counter', 'settings'], // only sync these slices
// exclude: ['auth'], // or exclude specific slices
})The enhancer diffs top-level state keys after each dispatch rather than replaying actions. This guarantees consistency regardless of reducer purity and works with any middleware stack.
A floating development panel for inspecting tab-bridge state, tabs, and events in real time.
import { TabSyncDevTools } from 'tab-bridge/react';
function App() {
return (
<TabSyncProvider options={{ initial: { count: 0 }, channel: 'app' }}>
<MyApp />
{process.env.NODE_ENV === 'development' && <TabSyncDevTools />}
</TabSyncProvider>
);
}| Tab | Description |
|---|---|
| State | Live JSON view of current state + manual editing (textarea → Apply) |
| Tabs | Active tab list with leader badge and "you" indicator |
| Log | Real-time event stream — state changes, tab joins/leaves |
| Prop | Type | Default | Description |
|---|---|---|---|
position |
'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' |
'bottom-right' |
Panel position |
defaultOpen |
boolean |
false |
Start expanded |
Tree-shakeable — if you never import TabSyncDevTools, it won't appear in your production bundle.
Using tab-bridge with Next.js App Router? Since tab-bridge relies on browser APIs, all usage must be in Client Components.
// app/providers/tab-sync-provider.tsx
'use client';
import { TabSyncProvider } from 'tab-bridge/react';
export function AppTabSyncProvider({ children }: { children: React.ReactNode }) {
return (
<TabSyncProvider options={{ initial: { count: 0 }, channel: 'my-app' }}>
{children}
</TabSyncProvider>
);
}// app/layout.tsx
import { AppTabSyncProvider } from './providers/tab-sync-provider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html><body>
<AppTabSyncProvider>{children}</AppTabSyncProvider>
</body></html>
);
}Full guide: See
docs/NEXTJS.mdfor SSR safety patterns, hydration mismatch prevention,useEffectinitialization, and Zustand integration with Next.js.
Structured errors with error codes for precise catch handling:
import { TabSyncError, ErrorCode } from 'tab-bridge';
try {
await sync.call('leader', 'getData');
} catch (err) {
if (err instanceof TabSyncError) {
switch (err.code) {
case ErrorCode.RPC_TIMEOUT: // call timed out
case ErrorCode.RPC_NO_LEADER: // no leader elected yet
case ErrorCode.RPC_NO_HANDLER: // method not registered on target
case ErrorCode.DESTROYED: // instance was destroyed
}
}
}
// Global error handler
createTabSync({ onError: (err) => Sentry.captureException(err) });🔌 Custom Transport Layer
import { createChannel } from 'tab-bridge';
createTabSync({ transport: 'local-storage' });
const channel = createChannel('my-channel', 'broadcast-channel');🔢 Protocol Versioning
All messages include a version field. The library automatically ignores messages from incompatible protocol versions, enabling safe rolling deployments — old and new tabs can coexist without errors.
import { PROTOCOL_VERSION } from 'tab-bridge';
console.log(PROTOCOL_VERSION); // 1🐛 Debug Mode
createTabSync({ debug: true });Outputs colored, structured logs:
[tab-sync:a1b2c3d4] → STATE_UPDATE { theme: 'dark' }
[tab-sync:a1b2c3d4] ← LEADER_CLAIM { createdAt: 1708900000 }
[tab-sync:a1b2c3d4] ♛ Became leader
🧩 Exported Internals
For library authors or advanced use cases, all internal modules are exported:
import {
StateManager, TabRegistry, LeaderElection, RPCHandler,
Emitter, createMessage, generateTabId, monotonic,
} from 'tab-bridge';Try these demos live — open multiple tabs to see real-time synchronization in action:
| Demo | Description | Features |
|---|---|---|
| Collaborative Editor | Multi-tab real-time text editing | State Sync, Typing Indicators |
| Shopping Cart | Cart synced across all tabs + persistent | State Sync, Persistence |
| Leader Dashboard | Only leader fetches data, followers use RPC | Leader Election, RPC, callAll |
| Full Feature Demo | All features in one page | Everything |
🔐 Shared Authentication State
const auth = createTabSync({
initial: { user: null, token: null },
channel: 'auth',
persist: { include: ['token'] },
});
auth.on('user', (user) => {
if (user) showDashboard(user);
else redirectToLogin();
});
function logout() {
auth.patch({ user: null, token: null }); // logout everywhere
}🌐 Single WebSocket Connection (Leader Pattern)
const sync = createTabSync({
initial: { messages: [] as Message[] },
});
sync.onLeader(() => {
const ws = new WebSocket('wss://chat.example.com');
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
sync.set('messages', [...sync.get('messages'), msg]);
};
return () => ws.close(); // cleanup on leadership loss
});🔔 Cross-Tab Notifications
interface NotifyRPC {
notify: {
args: { title: string; body: string };
result: void;
};
}
const sync = createTabSync<{}, NotifyRPC>({ channel: 'notifications' });
sync.onLeader(() => {
sync.handle('notify', ({ title, body }) => {
new Notification(title, { body });
});
return () => {};
});
await sync.call('leader', 'notify', {
title: 'New Message',
body: 'You have 3 unread messages',
});🛒 React — Shopping Cart Sync
interface CartState {
items: Array<{ id: string; name: string; qty: number }>;
total: number;
}
function Cart() {
const { state, set } = useTabSync<CartState>();
const itemCount = useTabSyncSelector<CartState, number>(
(s) => s.items.reduce((sum, i) => sum + i.qty, 0),
);
return (
<div>
<h2>Cart ({itemCount} items)</h2>
{state.items.map(item => (
<div key={item.id}>
{item.name} × {item.qty}
</div>
))}
</div>
);
}| Browser | Version | Transport | |
|---|---|---|---|
| 🟢 | Chrome | 54+ | BroadcastChannel |
| 🟠 | Firefox | 38+ | BroadcastChannel |
| 🔵 | Safari | 15.4+ | BroadcastChannel |
| 🔷 | Edge | 79+ | BroadcastChannel |
| ⚪ | Older browsers | — | localStorage (auto-fallback) |