Skip to content

Cross-Tab Sync

persist writes on every change, store → storage, but nothing pulls the other direction. Open the same app in two tabs, edit the store in one, and the other tab's live state doesn't know anything changed until it's reloaded. This recipe closes that gap: no new plugin, just store.persist.hydrate() called in response to a cross-tab signal.

Using the storage event

For the common case, persist backed by localStorage, the browser already tells other tabs when a key changes. The storage event fires only in tabs that didn't make the write, so there's no risk of a tab reacting to its own change:

ts
import { withPlugins } from "@kin-store/core";
import { persist } from "@kin-store/plugins";

const KEY = "todos";

const store = withPlugins({ items: [] as string[] })
  .use("persist", persist({ key: KEY }))
  .use({
    reducers: {
      addTodo: (s, text: string) => ({ items: [...s.items, text] }),
    },
  });

window.addEventListener("storage", (event) => {
  if (event.key === KEY) {
    store.persist.hydrate();
  }
});

Filtering on event.key matters: storage fires for any key changing on the origin, not just this store's.

Using BroadcastChannel

The storage event only fires for real localStorage/sessionStorage writes. If persist is configured with a custom storage backend (an IndexedDB wrapper, say), it won't fire at all. BroadcastChannel works regardless of the backend, since the tab announces the change itself instead of relying on the browser to notice a storage write:

ts
const channel = new BroadcastChannel("todos-sync");

store.subscribe(() => channel.postMessage("changed"));

channel.addEventListener("message", () => {
  store.persist.hydrate();
});

A BroadcastChannel object never receives its own posted messages, the same origin-tab exclusion the storage event has, so this needs no reentrancy guard either: postMessage here only ever reaches other tabs' channels.

WARNING

Both approaches only reach tabs that are already open when the message is sent. A tab opened later still starts with the correct value, because that comes from persist's normal hydration on startup, not from a signal it never saw.

Without persist

Both recipes above lean on persist: state round-trips through storage, and the browser (or the tab itself) just signals "go read it again." A store that doesn't use persist at all has no storage to re-read, but its state can be broadcast directly instead, the way jotai's atomWithBroadcast does it: post the new state on every change, and apply whatever arrives.

That's exactly what the broadcast plugin does:

ts
import { withPlugins } from "@kin-store/core";
import { broadcast } from "@kin-store/plugins";

const store = withPlugins({ items: [] as string[] })
  .use({
    reducers: {
      addTodo: (s, text: string) => ({ items: [...s.items, text] }),
    },
  })
  .use("broadcast", broadcast({ name: "todos" }));

store.dispatch.addTodo("hello"); // seen by other tabs sharing the "todos" channel

It's worth reaching for the plugin instead of hand-rolling this one: unlike the storage/persist.hydrate() recipes above, a message here carries the state itself, not just a change signal, and BroadcastChannel delivers it through the structured clone algorithm, a fresh object on every hop. That breaks the reentrancy trick the other two recipes rely on (store.set only notifies when the new state differs by Object.is, but a cloned object is never === the original), so applying an incoming message the naive way re-notifies, which re-broadcasts, which the other tab re-applies, forever. broadcast guards against this with an explicit re-entrancy flag instead, and tags each message with a clock so a slow reply to an old "what's the current state?" request can't clobber a newer change that already arrived by a faster path. See the plugin's docs for the full behavior, including its request/response handshake for tabs opened after others.

MIT License. Comparison