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:
import { withPlugins } from "@kintools/store-core";
import { persist } from "@kintools/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:
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:
import { withPlugins } from "@kintools/store-core";
import { broadcast } from "@kintools/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" channelIt'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.