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) |