Shared state
Let’s take shared state beyond the counter. In this guide we’ll pick how far
a value travels, namespace keys so features can’t collide, watch two tabs
fight over one key (and agree anyway), and reach the same state from code
that isn’t React. If you want the raw API surface instead, that’s
useSharedState.
Everything starts from one line:
const [note, setNote] = useSharedState('note', '');useState with a bigger blast radius: the value lives in every tab, window,
and worker on your origin.
Choose how far a value travels
Section titled “Choose how far a value travels”Not every value should reach everywhere. The scope option delimits the
blast radius:
useSharedState('draft', '', { scope: 'everywhere' }); // tabs + windows + workers (default)useSharedState('draft', '', { scope: 'tabs' }); // ignore writes from workersuseSharedState('draft', '', { scope: 'tab' }); // this tab onlyeverywhere— synced across every context on the origin. The default, and right for most things.tabs— synced, but writes coming from workers are silently ignored. Useful when a worker feeds a store that the UI sometimes wants to override locally.tab— never leaves the tab, but still shared between every component in it. A zero-Provider way to share state within one page.
Each scope is an independent namespace: a tab-scoped 'draft' and an
everywhere-scoped 'draft' are different values. Scope is part of the
identity — nothing ever bleeds between scopes by accident.
Namespace keys with stores
Section titled “Namespace keys with stores”Keys live inside a named store (default 'use-everywhere'). Give each
feature area its own store and stop thinking about key collisions:
const [step] = useSharedState('step', 0, { store: 'checkout' });const [step2] = useSharedState('step', 0, { store: 'onboarding' }); // unrelatedSharing an origin with code you don’t control (micro-frontends, embedded
apps)? Prefix the store name — 'myapp:checkout' — because same origin +
same name = same bus, for everyone.
Watch a conflict resolve
Section titled “Watch a conflict resolve”Here’s the scenario that scares people off cross-tab state, so let’s walk
straight into it. Tab A and tab B both write note in the same millisecond:
| Moment | Tab A (aaaaaa) | Tab B (bbbbbb) |
|---|---|---|
| write | note = "from A" | note = "from B" |
| receive | B’s write is “newer” (tie-break) → shows B | A’s write is older → keeps B |
| result | "from B" | "from B" |
Every key carries a version clock; every peer applies the same deterministic
rule (higher counter wins, ties broken by client id); every tab lands on the
same value — with no coordinator and no extra round trips. A third tab
receiving the patches in either order also lands on "from B". The full
mechanics are in How sync works.
The honest cost: tab A’s write was discarded, not merged. For flags, counters, form fields, wizard steps — exactly what you want. For two people typing in one document — wrong tool; that’s CRDT territory. Writes to different keys never conflict at all, so splitting state across keys is the cheap way to avoid collisions entirely.
Trust the late joiner
Section titled “Trust the late joiner”Open a new tab mid-session and it renders the current state from its first paint. You don’t write any code for this — but it’s worth knowing why it works, because it’s the part that kills the duplicate-tab-payment class of bug:
- The new tab broadcasts
hello; every existing peer answers with a snapshot of state and versions. - Your
initialvalue registers at version zero. - Anything a peer actually wrote has a higher version — so it beats your initial, always.
useSharedState('pay-status', 'idle') in a fresh tab hydrates to
'processing' if that’s the truth out there. The initial value never stomps
a real one.
Reach the state from outside React
Section titled “Reach the state from outside React”Workers, plain modules, event handlers outside components — the core engine is the same one the hooks use:
import { createSharedStore } from '@use-everywhere/core';
const store = createSharedStore('checkout', { step: 0 });store.state.step++; // proxy writes sync everywherestore.set('step', (prev) => prev + 1); // functional updates toostore.subscribe((key, value, meta) => { console.log(key, value, meta.clientId, meta.self ? '(me)' : '(other tab)');});And in React code, getSharedStore(name, scope) returns the exact store
instance the hooks use — handy for patch logs and imperative writes:
import { getSharedStore, DEFAULT_NAME } from 'use-everywhere';
getSharedStore(DEFAULT_NAME).set('count', 0); // resets every tab's counterReplace values, don’t mutate them
Section titled “Replace values, don’t mutate them”Shared state syncs on replacement, not mutation. Assigning a value — a
setter call, store.set(...), or a whole-value write through the proxy —
bumps the key’s version clock and broadcasts it. Reaching inside a value
and changing it in place does not:
store.set('cart', { items: 2 }); // ✅ syncs — new value, new versionstore.state.cart.items = 3; // ❌ silently local — no version bump, no broadcastThe state proxy is shallow, so a nested write never reaches the trap that
would version and broadcast it; the value just diverges between tabs. The fix
is always the same — build the next value and assign it:
const [cart, setCart] = useSharedState('cart', { items: 0 });setCart({ ...cart, items: cart.items + 1 }); // ✅ replace, never mutateTo make the mistake impossible to miss, shared values are deep-frozen in
development, so an accidental in-place mutation throws a TypeError right at
the offending line instead of failing quietly. Production builds strip the
freeze entirely — it costs you nothing shipped. (Same discipline as Redux
state; the reasoning is structured clone:
values must be plain data anyway.)
Where to next
Section titled “Where to next”useSharedState— the full option and gotcha reference for the hook.- Recipes — the duplicate-tab lock, the live draft, and the worker engine, built from this page’s pieces.
- How sync works — version clocks and handshakes, step by step.