Skip to main content

Why this exists

The bugs are familiar. A user opens checkout in a second tab and clicks Pay in both. A logout in one tab leaves five other tabs happily showing the inbox. A draft typed in tab A doesn't exist in tab B.

They're all the same bug: each browser tab runs its own copy of your app. In-tab state is a thoroughly solved space — React state, Redux, Zustand, pick your favorite — but everything those tools manage lives inside one tab. The tab next door holds an independent copy, and the moment there are two tabs, "application state" is really "this tab's opinion about application state."

The part the platform already solved

Here's the honest starting point: the browser already ships very good APIs for this.

  • BroadcastChannel is a named, origin-wide message bus. Any tab, window, iframe, or worker on your origin can post to a channel and hear everyone else — structured-clone payloads, no polyfills, universal support.
  • window.postMessage is the deliberate, security-shaped bridge between windows on different origins — the only one there is, and a well-designed one.

These two APIs are exactly what runs under use-everywhere's hood. The library doesn't replace them or work around them; it's built on the premise that they're the right primitives.

The gap

What's missing isn't transport — it's the abstraction. There is no useState-shaped way to say "this value exists in every tab." Both APIs are messaging primitives, and they intentionally leave the state questions to you: what a freshly opened tab should see (there's no history), who sent a message (there's no identity), what happens when two tabs write at once (there's no conflict rule), and, across origins, when the other window is actually ready to listen.

So every team answers those questions in app code. For the duplicate-tab payment, that typically looks like this:

// 1. take a cross-tab lock so only one tab can pay
async function tryPay() {
await navigator.locks.request('payment-48-291', { ifAvailable: true }, async (lock) => {
if (!lock) return showBlocked();
broadcastStatus('processing');
await chargeCard();
localStorage.setItem('paid-48-291', '1');
broadcastStatus('paid');
});
}

// 2. hand-wire a channel to tell other tabs
const bc = new BroadcastChannel('pay-48-291');
function broadcastStatus(s) {
bc.postMessage({ s, tab: TAB_ID });
applyStatus(s, TAB_ID); // BroadcastChannel doesn't echo to the sender
}
bc.onmessage = (e) => applyStatus(e.data.s, e.data.tab);

// 3. late joiner? BroadcastChannel has no history,
// so ALSO check localStorage on load…
if (localStorage.getItem('paid-48-291')) applyStatus('paid');

// 4. …and listen for storage events as a fallback
addEventListener('storage', (e) => {
if (e.key === 'paid-48-291') applyStatus('paid');
});

// 5. release / cleanup on unload, handle the lock holder
// crashing mid-payment, sync disabled button state,
// show WHICH tab is paying… 😩

This code is fine. That's worth saying plainly: it takes a lock, tells the other tabs, gives late joiners a sidecar to read on load. With care and a few tests, it's a perfectly correct solution — variants of it (swap in storage events, or a SharedWorker) are running in production everywhere.

The question is whether you want to implement it each time. I don't. It's three browser APIs and two sources of truth to keep agreeing, per feature — and the edge cases (the tab opened mid-payment, the lock holder crashing, cleanup on unload) each deserve their own test. All of that for what is, from the product's point of view, one status flag. This is boilerplate in the truest sense: necessary, repetitive, and identical in shape from app to app — which is exactly what a library should absorb.

use-everywhere minds that gap

I'd rather have something that wraps those primitives nicely, so that's what this is:

The spec, in one sentence

One object. Every tab. Writes anywhere show up everywhere, tabs opened later see the current value, and simultaneous writes don't split-brain.

  • The whole snippet above becomes one hookuseSharedState('pay-status', 'idle') — one source of truth instead of two, zero cleanup code.
  • Late joiners are handled. A new tab hydrates to the current value from its first frame; initial values never overwrite real ones.
  • Conflicts resolve deterministically. Simultaneous writes converge on the same winner in every tab — no split brain, no arbitration code.
  • Typed end to end. State, events, and cross-origin messages are all checked against types you declare once.
  • Presence is built in. "Who else has this open?" is a hook, not a heartbeat protocol you maintain.
  • The cross-origin channel ships the handshake and the validation — ready signaling, queueing, origin checks, session nonces — that raw postMessage leaves to each listener.
  • It stays small and honest. Zero dependencies, tree-shakeable, no runtime or worker bundle — the two platform APIs plus the state machinery they were missing. And everything is testable without a browser.

Row by row, this is the gap being closed:

NeedThe platform gives youSomeone has to build
Current value for a new tabnothing (no history)snapshot protocol or localStorage sidecar
Who sent this?nothingtab IDs
Concurrent writesnothingsome conflict rule, usually "hope"
Who else is open?nothingheartbeats and timeouts
Wait for a child window to loadnothingretrying ready handshake + queue
Sender validationevent.origin, event.sourcethe actual checks, in every listener
Session bindingnothingnonces
"The window closed"nothing reliablepolling child.closed

That table is the library — every row built once, tested, and hidden behind a useState-shaped API. The same-origin rows become useSharedState, useMessage, and usePeers; the cross-origin rows become useOpenedWindow.

Where to next

  • Getting started — the two-minute version, if you skipped ahead.
  • The mental model — the two ideas everything else follows from.
  • How sync works — how version clocks and the late-joiner handshake close the gaps above.
  • Security model — how the cross-origin channel uses postMessage the way it was designed to be used.