Skip to content

serbi2012/tab-bridge


πŸ”„ tab-bridge

Real-time State Synchronization Across Browser Tabs

One function call. Every tab in sync. Zero dependencies.


npm version bundle size TypeScript license GitHub stars


tab-bridge sync diagram

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-bridge
import { 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,
});
πŸ“‹ 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

Instance Methods

πŸ“Š 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 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 */ },
    },
  ],
});

middleware pipeline diagram




πŸ’Ύ 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 β€” 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.




🐻 Zustand

One-line integration for Zustand stores β€” all tabs stay in sync automatically.

npm install zustand
import { 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');
        });
      },
    }
  )
);



πŸ§ͺ Jotai

Synchronize individual Jotai atoms across browser tabs with zero boilerplate.

npm install tab-bridge jotai

Quick 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/toolkit

Quick 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.md for SSR safety patterns, hydration mismatch prevention, useEffect initialization, 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

architecture diagram

How State Sync Works

state sync sequence diagram

How Leader Election Works

leader election sequence diagram




πŸ”§ Advanced

πŸ”Œ 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';



πŸ’‘ 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

πŸ” 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)
websocket leader pattern diagram
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 Support

Browser Version Transport
🟒 Chrome 54+ BroadcastChannel
🟠 Firefox 38+ BroadcastChannel
πŸ”΅ Safari 15.4+ BroadcastChannel
πŸ”· Edge 79+ BroadcastChannel
βšͺ Older browsers β€” localStorage (auto-fallback)



πŸ“„ License

MIT Β© serbi2012


If this library helped you, consider giving it a ⭐


GitHub Β  npm

About

Zero-dependency cross-tab state sync, leader election & RPC for browser tabs

Topics

Resources

License

Code of conduct

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors