react-tab-state-sync
v0.1.0
Published
Lightweight React tab state synchronization with BroadcastChannel, IndexedDB durability, and deeplink intent bootstrapping.
Readme
react-tab-state-sync
Cross-tab state synchronization for React apps using BroadcastChannel-first transport, durable persistence, and deeplink intent bootstrapping.
Why this package exists
Modern React apps are often opened in multiple tabs. Users expect meaningful state (cart, selected workspace, filters, drafts) to stay in sync across tabs and survive reloads. This package gives you a scoped, opt-in sync layer without forcing a state manager. Warning: Do not sync the entire React app state. Sync only durable, meaningful state such as selected workspace, cart, filters, drafts, preferences, and auth/session changes.
Features
- BroadcastChannel as primary transport
- localStorage storage-event fallback transport
- IndexedDB persistence (preferred) with localStorage/memory alternatives
- Last-write-wins version conflict resolution (pluggable)
- Deeplink intent parsing and controlled application strategies
- Optional leader election for single-tab side effects
- SSR-safe imports (no window access at module top-level)
Installation
npm install react-tab-state-syncQuick start
import { useSyncedState } from "react-tab-state-sync";
const [cart, setCart] = useSyncedState("cart", { items: [], coupon: null }, {
namespace: "my-app",
persist: true,
storage: "indexeddb",
syncKeys: ["items", "coupon"],
debounceMs: 50,
});Core Hooks & Examples
1. useSyncedState (Durable State Synchronization)
Used for component-local state that must be kept in sync across tabs and persisted across page reloads. Excellent for state like shopping carts, preferences, draft forms, or filter lenses:
import { useSyncedState } from "react-tab-state-sync";
const UserPreferences = () => {
const [prefs, setPrefs] = useSyncedState("user-prefs", { theme: "light", compact: false }, {
namespace: "my-app",
persist: true,
storage: "indexeddb", // Prefers IndexedDB, falls back automatically
syncKeys: ["theme", "compact"], // Only sync these fields, ignore others
});
return (
<button onClick={() => setPrefs(prev => ({ ...prev, theme: prev.theme === "light" ? "dark" : "light" }))}>
Toggle Theme (Current: {prefs.theme})
</button>
);
};2. useSyncedReducer (Complex Collaborative State)
Used when your state changes are complex, actions are structured, or you need to ensure state is transformed identically across all tabs:
import { useSyncedReducer } from "react-tab-state-sync";
type Todo = { id: string; text: string; done: boolean };
type TodoAction =
| { type: "ADD"; payload: string }
| { type: "TOGGLE"; payload: string };
const todoReducer = (state: Todo[], action: TodoAction): Todo[] => {
switch (action.type) {
case "ADD":
return [...state, { id: Math.random().toString(36), text: action.payload, done: false }];
case "TOGGLE":
return state.map(todo => todo.id === action.payload ? { ...todo, done: !todo.done } : todo);
default:
return state;
}
};
const TodoList = () => {
const [todos, dispatch] = useSyncedReducer("todos", todoReducer, [], {
namespace: "my-todo-app",
persist: true,
});
return (
<div>
<button onClick={() => dispatch({ type: "ADD", payload: "New Task" })}>Add Todo</button>
{todos.map(todo => (
<label key={todo.id}>
<input type="checkbox" checked={todo.done} onChange={() => dispatch({ type: "TOGGLE", payload: todo.id })} />
{todo.text}
</label>
))}
</div>
);
};3. useCrossTabEvent (Session & Logout Fan-out)
Used to emit one-time, transient events to other tabs. Typical use cases include logging out all tabs, displaying a global success toast, or triggering background syncs:
import { useCrossTabEvent, logout } from "react-tab-state-sync";
const App = () => {
// Listen for the 'logout' event from other tabs
useCrossTabEvent<{ at: number }>("logout", (event) => {
console.log("Logged out from tab:", event.sourceTabId);
window.location.href = "/login";
}, { namespace: "my-app", includeSelf: false });
const handleLogout = () => {
// Broadcast logout event to all other open tabs
logout.emit({ at: Date.now() }, { namespace: "my-app" });
window.location.href = "/login";
};
return <button onClick={handleLogout}>Log Out Everywhere</button>;
};4. useIsLeaderTab (Leader-only WebSockets & Telemetry)
If your app opens WebSockets or emits telemetry, having 5 open tabs will duplicate connections and events. Use leader election to ensure only a single tab runs these side effects:
import { useEffect } from "react";
import { useIsLeaderTab } from "react-tab-state-sync";
const WebSocketConnector = () => {
const isLeader = useIsLeaderTab({
namespace: "my-app",
leaderElection: { enabled: true },
});
useEffect(() => {
if (!isLeader) return;
// Only the single leader tab establishes this WebSocket connection
const socket = new WebSocket("wss://api.example.com/live");
socket.onmessage = (event) => {
// Receive live updates and optionally publish them to other tabs
};
return () => socket.close();
}, [isLeader]);
return <div>Status: {isLeader ? "Active Connection Leader" : "Standby Follower"}</div>;
};5. Low-Level Pub/Sub (useTabSync)
If you need to subscribe to raw synchronization events or send messages without tying them to React render loops or local state, use useTabSync:
import { useTabSync } from "react-tab-state-sync";
const CustomSync = () => {
const sync = useTabSync<{ message: string }>({ namespace: "custom-events" });
useEffect(() => {
const unsubscribe = sync.subscribe((message) => {
console.log("Received cross-tab message:", message.payload.message);
});
return () => unsubscribe();
}, [sync]);
const sendMessage = () => {
sync.publish({
type: "CUSTOM",
payload: { message: "Hello from another tab!" },
});
};
return <button onClick={sendMessage}>Send Raw Event</button>;
};Run the Vite example app
This repository includes a polished React + TypeScript example at examples/react-basic.
It demonstrates cart state syncing, filters syncing, deeplink-opened tabs, IndexedDB
durable reloads, leader-tab status, active tab count, logout across all tabs, and
clearing persisted state.
cd examples/react-basic
npm install
npm run devThe example imports react-tab-state-sync by package name; Vite aliases that import to
the local src/ directory so you can develop the package and example together without
publishing first.
Open the Vite URL in multiple tabs, or use the in-app deeplink button to open
/project/phoenix?view=pending in a new tab. Refresh a tab to verify IndexedDB
restores the synced cart and filters.
Deeplink example
Deeplinks are treated as initial user intent, not as persistent state and not as a replacement for durable storage. Parse only the URL fields that represent what the user is trying to open, then apply that intent with an explicit strategy. This avoids blindly overwriting durable state restored from IndexedDB/localStorage or received from another tab.
import {
applyDeeplinkIntent,
broadcastDeeplinkIntent,
parseDeeplinkIntent,
} from "react-tab-state-sync";
const intent = parseDeeplinkIntent(window.location, {
query: {
project: "selectedProjectId",
view: "currentView",
},
hash: {
panel: "activePanel",
},
path: {
pattern: "/projects/:project/views/:view",
params: {
project: "selectedProjectId",
view: "currentView",
},
},
}, { ttlMs: 30_000 });
const nextState = applyDeeplinkIntent(currentState, intent, {
strategy: "replaceSelectedKeys",
keys: ["selectedProjectId", "currentView"],
});
// Optional: tell other tabs about the same one-time intent.
broadcastDeeplinkIntent(tabSync, intent, { ttlMs: 30_000 });Schema entries map URL parameter names to typed state/intent keys. Use { key, parse }
for custom parsing:
const intent = parseDeeplinkIntent(window.location, {
query: {
page: { key: "page", parse: (value) => Number(value) },
compact: { key: "compactMode", parse: (value) => value === "1" },
},
});applyDeeplinkIntent supports:
"merge"/{ strategy: "merge" }: merge only the parsed intent keys into state{ strategy: "replaceSelectedKeys", keys: [...] }: replace only the allowlisted keys{ strategy: "custom", apply }: fully custom application logic
Parsed intents are one-shot: applying the same parsed intent object a second time returns
the current state unchanged. Intents can also be created with a TTL; expired intents are
ignored during application. All deeplink APIs are SSR-safe and avoid reading window at
module load time.
Leader election example
import { useIsLeaderTab } from "react-tab-state-sync";
const isLeader = useIsLeaderTab({
namespace: "my-app",
leaderElection: { enabled: true },
});Persistence example
storage: "indexeddb"preferred durable modestorage: "localstorage"fallbackstorage: "memory"useful for tests
Conflict resolution
Default resolver:
- Newer
versionwins - If equal, newer
timestampwins - If equal, lexicographically larger
sourceTabIdwins ProvideconflictResolverto customize behavior.
Performance guidance
- Use
syncKeysto limit payload size - Debounce high-frequency updates (
debounceMs) - Avoid syncing transient UI state (hover, ephemeral animation flags)
- Keep payloads small
- Use custom adapters for fine-grained store slices
What not to sync
- Huge normalized caches
- transient animation state
- hover/open UI-only toggles
- non-durable derived values
Browser support
- BroadcastChannel: modern browsers
- localStorage fallback transport: broad support
- IndexedDB persistence: broad support; package gracefully degrades when unavailable
API reference
Core:
createTabSync(options)publish(message)subscribe(handler)requestState()respondWithState(state)getTabId()getLeaderStatus()acquireLeadership()releaseLeadership()destroy()React:useSyncedStateuseSyncedReduceruseTabSyncuseDeeplinkIntentuseIsLeaderTabAdapters:createStoreSyncAdaptersyncExternalStore
Reliability considerations
This package handles:
- malformed message filtering
- message deduplication via
messageId - fallback transport
- missing browser APIs in SSR/private contexts
- version conflict resolution
- tab close signaling
- rapid updates (via debounce strategy)
Schema upgrades can be handled with
schemaVersionandmigrationhooks increateTabSyncoptions.
Design decisions
- Keep v1 lightweight and dependency-free
- Last-write-wins (LWW) instead of CRDTs
- Treat deeplink as user intent, not durable source of truth
- Store-agnostic integration to avoid state-manager lock-in
Limitations
- Leader election uses heartbeat strategy in v1 (Web Locks integration can be layered later)
- Deep structural diffing is intentionally omitted for bundle-size/performance; custom diff can be done upstream
- No backend reconciliation (client-only sync)
