npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Quick 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 dev

The 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 mode
  • storage: "localstorage" fallback
  • storage: "memory" useful for tests

Conflict resolution

Default resolver:

  1. Newer version wins
  2. If equal, newer timestamp wins
  3. If equal, lexicographically larger sourceTabId wins Provide conflictResolver to customize behavior.

Performance guidance

  • Use syncKeys to 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:
  • useSyncedState
  • useSyncedReducer
  • useTabSync
  • useDeeplinkIntent
  • useIsLeaderTab Adapters:
  • createStoreSyncAdapter
  • syncExternalStore

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 schemaVersion and migration hooks in createTabSync options.

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)