tab-bridge
v0.4.0
Published
Zero-dependency TypeScript library for real-time state synchronization across browser tabs, with leader election and cross-tab RPC
Maintainers
Readme
Getting Started · API · React · Zustand · Jotai · Redux · DevTools · Next.js · Architecture · Examples · Live Demo
Why tab-bridge?
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.
✨ Feature Highlights
⚡ State Sync
LWW conflict resolution with batched broadcasts and custom merge strategies
👑 Leader Election
Bully algorithm with heartbeat monitoring and automatic failover
📡 Cross-Tab RPC
Fully typed arguments, Promise-based calls with callAll broadcast support
🔄 Atomic Transactions
transaction() for safe multi-key updates with abort support
⚛️ React Hooks
7 hooks built on useSyncExternalStore — zero-tear concurrent rendering
🛡️ Middleware Pipeline
Intercept, validate, and transform state changes before they're applied
💾 State Persistence
Survive page reloads with key whitelisting and custom storage backends
🐻 Zustand / Jotai / Redux
First-class integrations — tabSync, atomWithTabSync, tabSyncEnhancer
🔧 DevTools Panel
Floating <TabSyncDevTools /> with state inspection, tab list, and event log
📦 Zero Dependencies
Native browser APIs only, ~4KB gzipped, fully tree-shakable
📦 Getting Started
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); // 42📖 API Reference
createTabSync<TState, TRPCMap>(options?)
The single entry point. Returns a fully-typed TabSyncInstance.
const sync = createTabSync<MyState>({
initial: { theme: 'light', count: 0 },
channel: 'my-app',
debug: true,
});| 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 |
Instance Methods
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() };
});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 },
);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-basedsync.id // This tab's UUID
sync.getTabs() // → TabInfo[]
sync.getTabCount() // → number
sync.onTabChange((tabs) => {
console.log(`${tabs.length} tabs open`);
});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 }>sync.ready // false after destroy
sync.destroy() // graceful shutdown, safe to call multiple times🔷 Typed RPC
Define 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[]🛡️ Middleware
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 */ },
},
],
});💾 Persistence
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
},
});⚛️ React
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 options={{ initial: { count: 0 }, channel: 'app' }}>
<App />
</TabSyncProvider>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>
);
}function ThemeDisplay() {
const theme = useTabSyncValue<MyState, 'theme'>('theme');
return <div className={`app ${theme}`}>Current theme: {theme}</div>;
}function DoneCount() {
const count = useTabSyncSelector<MyState, number>(
(state) => state.todos.filter(t => t.done).length,
);
return <span className="badge">{count} done</span>;
}function LeaderIndicator() {
const isLeader = useIsLeader();
if (!isLeader) return null;
return <span className="badge badge-leader">Leader Tab</span>;
}function TabList() {
const tabs = useTabs();
return <p>{tabs.length} tab(s) open</p>;
}function LeaderDisplay() {
const leader = useLeaderInfo();
if (!leader) return <p>No leader yet</p>;
return <p>Leader: {leader.id}</p>;
}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.
🐻 Zustand
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.| 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 |
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
}
)
);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' }
)
);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');
});
},
}
)
);🧪 Jotai
Synchronize individual Jotai atoms across browser tabs with zero boilerplate.
npm install tab-bridge jotaiQuick Start
import { 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.
Options
| 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 |
How It Works
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
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 tab🏪 Redux
Synchronize your Redux (or Redux Toolkit) store across browser tabs via a store enhancer.
npm install tab-bridge redux # or @reduxjs/toolkitQuick Start
import { 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.
Options
| 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 |
Selective Sync
tabSyncEnhancer({
channel: 'my-app',
include: ['counter', 'settings'], // only sync these slices
// exclude: ['auth'], // or exclude specific slices
})Design Decision: State-Based Sync
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.
🔧 DevTools
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>
);
}Features
| 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 |
Props
| 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.
📘 Next.js
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.
🚨 Error Handling
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) });🏗️ Architecture
How State Sync Works
How Leader Election Works
🔧 Advanced
import { createChannel } from 'tab-bridge';
createTabSync({ transport: 'local-storage' });
const channel = createChannel('my-channel', 'broadcast-channel');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); // 1createTabSync({ debug: true });Outputs colored, structured logs:
[tab-sync:a1b2c3d4] → STATE_UPDATE { theme: 'dark' }
[tab-sync:a1b2c3d4] ← LEADER_CLAIM { createdAt: 1708900000 }
[tab-sync:a1b2c3d4] ♛ Became leaderFor library authors or advanced use cases, all internal modules are exported:
import {
StateManager, TabRegistry, LeaderElection, RPCHandler,
Emitter, createMessage, generateTabId, monotonic,
} from 'tab-bridge';💡 Examples
🎯 Interactive Demos
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 |
Code Examples
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
}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
});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',
});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 Support
| | Browser | Version | Transport |
|:--|:--------|:--------|:----------|
| 🟢 | Chrome | 54+ | BroadcastChannel |
| 🟠 | Firefox | 38+ | BroadcastChannel |
| 🔵 | Safari | 15.4+ | BroadcastChannel |
| 🔷 | Edge | 79+ | BroadcastChannel |
| ⚪ | Older browsers | — | localStorage (auto-fallback) |
MIT © serbi2012
If this library helped you, consider giving it a ⭐
