Skip to main content

Limitations & FAQ

The library is deliberately small, and most "missing" features are boundaries chosen on purpose — knowing a tool's edges is how you trust its middle. Here's where each boundary is, why it's there, and what to use on the other side of it.

State does not survive the last tab — unless you ask it to

By default, replicas live in JS memory. Close every tab and the state is gone; reopen and you start from initial values. Nothing touches localStorage behind your back.

If you want it to survive, say so:

import { defineStore, localStorageAdapter } from 'use-everywhere';

const settings = defineStore('settings', { persist: localStorageAdapter('app:settings') });

See defineStore.

Do not hand-roll it with a write-through effect:

// Don't. This is subtly wrong.
const [draft, setDraft] = useSharedState('draft', localStorage.getItem('draft') ?? '');
useEffect(() => localStorage.setItem('draft', draft), [draft]);

Two things break. Every tab runs that effect, so N tabs race to write the same key. And the stored string carries no version clock, so when you reopen, the restored value re-enters the world at counter zero — it cannot win the last-writer-wins race even when it genuinely is the newest thing anyone has. defineStore persists the clocks along with the values, which is the whole reason it converges.

Persistence is best-effort

Storage can be unavailable: a sandboxed iframe, third-party cookies off, a full quota, a corrupt entry left by an older version of your app. In every one of those cases persistence degrades to a silent no-op and the store keeps working in memory. It will never be the thing that breaks your page — which also means it is not a guarantee. Don't put anything you can't afford to lose there; that's what your server is for.

Leadership is advisory, not a distributed lock

useLeader elects one tab, and for a short window — roughly one round trip, when two claims genuinely cross — two tabs can both believe they hold the seat before one concedes.

That is fine for "don't open five WebSockets" and wrong for "don't charge the card twice." Same energy as the note below about client-side locks: it is an efficiency mechanism, not a safety one. Anything that must happen exactly once needs a server-side idempotency key.

A hidden tab can lose a lease it deserved to keep

Browsers throttle timers in backgrounded pages — to roughly 1 Hz, and harder after a few minutes. A perfectly healthy leader that is merely hidden can therefore miss its heartbeats, get demoted by tabs that are still awake, and run the cleanup in useLeaderEffect.

The 3-second default lease tolerates 1 Hz clamping. If the work is expensive to restart, keep hidden tabs out of the running entirely:

useLeader({ eligible: !document.hidden });

The Inspector is a devtool, not a feature

<Inspector /> lives on the use-everywhere/devtools subpath so it stays out of your bundle unless you import it. Guard it behind a dev flag. It's built to observe without perturbing — it never joins the election it displays — but it is not a supported UI surface, and its markup and classnames are not a stable API.

Last-writer-wins loses concurrent writes

Two tabs writing the same key at the same moment converge — by discarding one write (see How sync works). Perfect for status flags, counters, and drafts; wrong for merging concurrent edits to one text document. That problem needs CRDTs (Yjs, Automerge) — out of scope by design.

Writes to different keys never conflict, so splitting state across keys is the cheap way to reduce collisions.

Values must survive structured clone

Both transports serialize with the structured clone algorithm. Plain objects, arrays, strings, numbers, Map, Set, Date, typed arrays: fine. Functions, DOM nodes, class instances (their prototypes), React elements: not fine. Keep shared state to serializable data — the same discipline as Redux.

Same device, same browser only

BroadcastChannel spans one origin in one browser profile on one machine. It does not reach the user's phone, their other browser, or incognito windows. Cross-device sync is a server problem; this library composes with whatever you use there — its job ends at the tab boundary.

No message history

Channel events are fire-and-forget. A tab that joins after logged-out fired never sees it. This is why the litmus test matters: anything a late joiner must know belongs in state, which re-hydrates.

Client-side locks are UX, not security

The single-flight lock prevents accidental double-pay by a confused user. A malicious user has DevTools. Your server still needs idempotency keys — please don't let a client library talk you out of them.

FAQ

Does it work in Safari / Firefox / Edge?

BroadcastChannel and postMessage have been universal for years (Safari ≥ 15.4 for BroadcastChannel). No polyfills are bundled or needed for evergreen browsers.

What about iframes?

A same-origin iframe joins buses like any tab. A cross-origin iframe is a different principal — the window channel's opener/child model targets popups (window.open), so for embedded iframes today you would wire postMessage yourself or open a popup instead.

Why is there no Provider?

A BroadcastChannel is already global to the origin; a React Provider could not scope it any further, so it would be ceremony without meaning. Identity is the name string — namespacing is useSharedState('count', 0, { store: 'checkout' }).

What happens to shared state when a component unmounts?

Nothing is disposed. Unmounting only removes that component's subscription; the store behind the hook is a module-level singleton that lives for the page lifetime. It keeps the value and its version clocks, stays on the bus, and keeps answering late-joiner handshakes — even while no component is subscribed. Remounting reads the current value straight from the store, not your initial (key registration is first-wins, so re-registering is a no-op).

If you drive the core directly and really want to tear a store down, store.close() unsubscribes it and releases the bus. The React layer never calls it — a value shouldn't vanish from other tabs because one tab's UI happened to unmount.

What happens when a tab closes — or the last one does?

Every tab holds a full replica, so a closing tab takes only its copy with it: it says goodbye on pagehide, drops off the bus, and presence prunes it. As long as one same-origin context (tab, window, or worker) is still open, the state lives there, and the next tab to open hydrates from it via the snapshot handshake.

When the last one closes, the state is gone — replicas are JS memory, and there is deliberately no storage underneath. A later tab broadcasts hello, nobody answers, and it starts from initial values. If that matters for your data, use the write-through pattern in State does not survive the last tab.

How big is it?

The core is dependency-free and tree-shakeable; the React layer adds hooks over useSyncExternalStore. There is no runtime, no scheduler, no worker bundle — just the two browser primitives underneath.

Can two different apps on the same origin interfere?

Yes — same origin, same name, same bus. That's occasionally a feature (micro-frontends sharing session state) and occasionally a hazard; prefix store names ('myapp:cart') when you don't control the whole origin.

When should I not use this?

  • Syncing across devices or users → server (WebSocket, SSE, polling).
  • Collaborative editing with merge semantics → CRDT libraries.
  • Durable offline data → IndexedDB (possibly plus this, for live sync).
  • One-tab apps → useState is right there.

Where to next

  • Why this exists — the problems the library does solve, and the plumbing it replaces.
  • How sync works — the machinery behind the trade-offs on this page.
  • Recipes — patterns designed around exactly these boundaries.