Contents
- The Problem
- Architecture Overview
- The Types & Interface
- Tab Identity & State Ref
- The BroadcastChannel Listener
- The Synchronized Mutation Function
- Lock Coordination & Broadcasting
- Graceful Degradation
- Key Architectural Safeguards
- Real-World Use Cases
- And the Github Link
The Problem
If you've ever built a multi-tab dashboard where multiple browser windows need to share the same state, you know the pain. You update something in one tab, and the other tab is still sitting on stale data.
And that's assuming you're not running into race conditions where two tabs try to update the same piece of state at the exact same time. Split-brain mutations, anyone? 😬
And yeah, it's not a fun problem to solve. But let me tell you, the solution is way simpler than you think.
React's useState is beautiful — until you need it to work across browser tabs. Each tab has its own isolated JavaScript context. When you call setState in one tab, the other tabs don't even flinch.
// Tab A does this
const [count, setCount] = useState(0);
setCount(5);
// Tab B still thinks count is 0
The naive solution is to poll every few seconds or set up a WebSocket. But what if I told you the browser already gives us the primitives we need? 🎯
And yes, the browser natively supports this. No extra libraries needed.
Today let's build a production-ready React custom hook using BroadcastChannel for instant fan-out state syncing and navigator.locks for deterministic write coordination.
Architecture Overview
TAB A TAB B TAB C
useSyncedState useSyncedState useSyncedState
| | |
|--- postMessage --- postMessage ---|
| | |
▼ ▼ ▼
┌───────────────────────────────────────┐
│ BroadcastChannel (IPC) │
│ Zero-latency fan-out to all tabs │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ navigator.locks │
│ Exclusive write mutex per tab │
│ request(lockName, {exclusive}) │
└───────────────────────────────────────┘
The hook combines two browser APIs into a single cohesive abstraction:
1. Race-Condition Exclusion (navigator.locks) — Every write acquires an exclusive mutex. Functional updaters like (prev) => prev + 1 resolve sequentially across all tabs without interleaved split-brain mutations.
2. Instant Fan-Out (BroadcastChannel) — Once a lock is obtained and state updates, the payload is broadcast to every listening context in parallel via browser IPC. Zero polling, zero latency.
3. Loop Prevention (senderId) — Outgoing payloads tag the current tab session via crypto.randomUUID(). Broadcast listeners ignore incoming packets generated by their own instance to eliminate redundant renders or echo loops.
4. Stale Closure Safety (stateRef) — Uses an internal mutable reference to state inside setSyncedState so state updater functions always evaluate against the latest values during fast-succession writes.
5. Browser Graceful Degradation — Fallbacks are built in if navigator.locks or BroadcastChannel are absent (e.g., SSR environments like Next.js). The hook still works — it just operates in local-only mode.
The Types & Interface
First, let's define the types our hook needs. We need an options interface for customization, and a message type for our BroadcastChannel communication.
export interface UseSyncedStateOptions<T> {
/** Optional custom channel/lock name prefix. Defaults to the provided key. */
name?: string;
/** Custom fallback logic if non-cloneable objects are used. */
serialize?: (value: T) => unknown;
deserialize?: (value: unknown) => T;
}
type SyncMessage<T> = {
type: 'STATE_UPDATE';
payload: T;
senderId: string;
};
The UseSyncedStateOptions lets users customize the channel name and provide custom serialization/deserialization for non-JSON-serializable state. The SyncMessage type is what we broadcast through the channel — every message has a type, payload, and a senderId to prevent echo loops.
Tab Identity & State Ref
Every tab instance needs a unique identity so it can ignore its own broadcast messages. Otherwise, when tab A broadcasts an update, tab A would receive its own message and re-render — an infinite echo loop.
const tabIdRef = useRef<string>(
typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).substring(2)
);
We use crypto.randomUUID() when available, falling back to a Math.random() string for older browsers. This ID is tagged onto every outgoing message so the sender can filter it out on receipt.
Now, we also need a mutable reference to state so that functional updaters always evaluate against the latest values. React closures capture stale values at render time.
const stateRef = useRef<T>(state);
useEffect(() => {
stateRef.current = state;
}, [state]);
This is critical for fast-succession writes. When you call
setCount((prev) => prev + 1)rapidly across tabs,stateRef.currentalways points to the latest value, not a captured snapshot from when the callback was created.
The BroadcastChannel Listener
Next, we set up the BroadcastChannel listener. This listens for state updates from sibling tabs and updates our local React state when a message arrives.
const channelRef = useRef<BroadcastChannel | null>(null);
useEffect(() => {
if (typeof window === 'undefined' || !('BroadcastChannel' in window)) {
return;
}
const channel = new BroadcastChannel(channelName);
channelRef.current = channel;
channel.onmessage = (event: MessageEvent<SyncMessage<T>>) => {
const data = event.data;
// Ignore messages sent by this specific tab instance
if (!data || data.type !== 'STATE_UPDATE' || data.senderId === tabIdRef.current) {
return;
}
const nextValue = options.deserialize
? options.deserialize(data.payload)
: data.payload;
setState(nextValue);
};
return () => {
channel.close();
channelRef.current = null;
};
}, [channelName, options.deserialize]);
Three things to note here:
-
We check for
BroadcastChannelsupport — if it doesn't exist (like in SSR), we early-return and the channel ref stays null. -
The
senderIdcheck —data.senderId === tabIdRef.currentensures we never process our own messages. This eliminates echo loops entirely. -
Cleanup on unmount — the
useEffectreturn closes the channel so we don't leak browser IPC resources.
The Synchronized Mutation Function
This is the core of the hook. The setSyncedState function is what replaces setState. It's async because it needs to coordinate across tabs.
const setSyncedState = useCallback(
async (value: T | ((prevState: T) => T)) => {
const updateFn = async () => {
const nextState =
typeof value === 'function'
? (value as (prevState: T) => T)(stateRef.current)
: value;
// 1. Optimistically update local React state
setState(nextState);
// 2. Broadcast state update to all sibling tabs
if (channelRef.current) {
const payload = options.serialize
? (options.serialize(nextState) as T)
: nextState;
const message: SyncMessage<T> = {
type: 'STATE_UPDATE',
payload,
senderId: tabIdRef.current,
};
channelRef.current.postMessage(message);
}
};
// Ensure write exclusivity using Web Locks API if available
if (typeof navigator !== 'undefined' && 'locks' in navigator) {
await navigator.locks.request(lockName, { mode: 'exclusive' }, updateFn);
} else {
await updateFn();
}
},
[lockName, options.serialize]
);
Let me break down what happens inside updateFn:
-
Functional updaters work too —
typeof value === 'function'checks if the user passed a callback like(prev) => prev + 1. If so, it evaluates againststateRef.currentto get the latest value. -
Optimistic update —
setState(nextState)fires immediately so the UI updates without waiting for cross-tab coordination. -
Broadcast —
channelRef.current.postMessage(message)sends the new state to every sibling tab.
Tab A Channel Tab B
| | |
|--- postMessage --- postMessage ---|
| | |
v v v
setSynced(5) broadcast state -> 5
Auto-updated! Re-render!
Step-by-step data flow:
1. Tab A calls setSyncedState(value) -> acquires exclusive lock
2. Optimistic update: Tab A UI updates immediately
3. BroadcastChannel sends message to ALL sibling tabs
4. Each tab receives message -> updates state -> re-renders
Lock Coordination & Broadcasting
The updateFn is wrapped inside a navigator.locks.request() call. This is the key to preventing race conditions.
if (typeof navigator !== 'undefined' && 'locks' in navigator) {
await navigator.locks.request(lockName, { mode: 'exclusive' }, updateFn);
} else {
await updateFn();
}
Here's why this matters. Without the lock, if two tabs call setCount((prev) => prev + 1) simultaneously, both would read 0, both would compute 1, and both would write 1 — ending up with 1 instead of 2.
WITHOUT LOCKS WITH LOCKS
================= =====================
Tab A reads count=0 Tab A acquires lock
Tab B reads count=0 Tab A writes 1 -> broadcast
Tab B acquires lock
Tab A writes count=1 Tab B reads 1
Tab B writes count=1 Tab B writes 2 -> broadcast
=====================
Result: count=1 ❌ Result: count=2 ✓
(should be 2!)
Timeline without locks: T0: A reads 0, B reads 0 -> T1: A writes 1, B writes 1 -> count=1 ❌
Timeline with locks: A reads 0 -> A writes 1 -> B reads 1 -> B writes 2 -> count=2 ✓
The lock ensures they resolve one after the other. Tab A acquires the lock, reads
0, writes1, broadcasts. Then Tab B acquires the lock, reads1, writes2, broadcasts. No split-brain mutations.
The lock name is derived from the channel name:
const lockName = `lock:${channelName}`;
And if navigator.locks isn't available (older browsers, SSR), we fall back to calling updateFn() directly — the hook still works, it just operates in local-only mode.
Graceful Degradation
Not every environment supports these APIs. Next.js SSR? No window. Older browsers? No navigator.locks. The hook handles all of this:
- If
BroadcastChanneldoesn't exist, theuseEffectearly-returns and the channel ref stays null — no error thrown - If
navigator.locksdoesn't exist,setSyncedStatefalls back to callingupdateFn()directly — no error thrown - If
cryptoisn't available, we fall back toMath.random().toString(36).substring(2)for tab IDs — no error thrown
The hook still works — it just operates in local-only mode instead of cross-tab mode. Same API, same behavior, just without the cross-tab sync.
This is the beauty of the approach. You write one hook, and it works everywhere. No conditional imports, no environment-specific code paths for the consumer.
Key Architectural Safeguards
Let me highlight the four safeguards that make this production-ready:
🔒 Race-Condition Exclusion (navigator.locks)
Every write operation acquires an exclusive mutex. Functional updaters like (prev) => prev + 1 resolve sequentially across all tabs without interleaved split-brain mutations.
Why this matters: Without the lock, two tabs calling
setCount((prev) => prev + 1)simultaneously could both read0, both write1, and you'd end up with1instead of2. The lock ensures they resolve one after the other.
⚡ Instant Fan-Out (BroadcastChannel)
Once a lock is obtained and state updates, the payload is broadcast to every listening context in parallel via browser IPC. Zero polling, zero latency.
🔁 Loop Prevention (senderId)
Outgoing payloads tag the current tab session via crypto.randomUUID(). Broadcast listeners ignore incoming packets generated by their own instance to eliminate redundant renders or echo loops.
Tab A Channel Tab B
| | |
| senderId=aaa | | senderId=bbb
|--- postMessage ->|-- broadcast ------>|--- postMessage ->
| | |
|<--- receives own ----| |
| message ✗ | |<-- receives msg
| IGNORED ✗ | | from A -> ACCEPTED ✓
How senderId prevents echo loops:
Each tab tags messages with its own UUID -> receivers ignore any message
where senderId matches their own ID -> no infinite echo cycles
How it works: Each tab gets a unique ID on mount. Every outgoing message includes this ID. When a tab receives a message, it checks if the
senderIdmatches its own — if it does, it ignores the message entirely.
🧠 Stale Closure Safety (stateRef)
Uses an internal mutable reference to state inside setSyncedState so state updater functions always evaluate against the latest values during fast-succession writes.
The problem: React closures capture values at render time. If you call
setCount((prev) => prev + 1)rapidly, the callback might close over a staleprevvalue.stateRef.currentalways points to the latest state.
Real-World Use Cases
This isn't just a toy problem. Here's where this hook shines:
Multi-Tab Admin Dashboards
You're building an admin panel. Tab 1 shows the live user count. Tab 2 shows the active orders feed. When a new order comes in, Tab 1 needs to update instantly — without polling the server every 2 seconds. With this hook, Tab 1's state syncs the moment Tab 2 receives the WebSocket event.
E-Commerce Cart Sync
A user has your product page open in one tab and their cart in another. They add an item to the cart in Tab 1. Tab 2's cart badge updates immediately — no refresh needed, no WebSocket library, no backend endpoint.
Form Builders & Multi-Step Wizards
You're building a multi-step form that spans multiple tabs. The user fills out step 1 in Tab A, switches to Tab B for step 2 — both tabs reflect the same form state. If they refresh one tab, the state persists because the other tab is broadcasting the latest snapshot.
Trading / Live Data Dashboards
Imagine a stock dashboard where Tab A shows a live chart and Tab B shows the order entry form. When the user places an order in Tab B, Tab A needs to update the portfolio balance instantly. BroadcastChannel + locks = zero-latency sync without setting up a WebSocket server.
Electron / Tauri Desktop Apps
Multiple windows in an Electron app need to share state. Tab A is the file browser, Tab B is the code editor. When a file is opened in Tab A, Tab B's editor updates. Same hook, same API — just runs in Electron's renderer process instead of a browser tab.
The pattern is always the same: multiple browser contexts need the same state, updated in real-time, without the overhead of a full WebSocket server or polling mechanism.
It's not a replacement for a backend state management solution when you need server-authoritative state or persistence across sessions. For that, combine this with a backend for the source of truth.
My take: This is the perfect middle ground when you want real-time cross-tab sync without the complexity of WebSockets or a backend.
And the Github Link
The full implementation with tests is available on GitHub: https://github.com/Akashdeep-Patra/use-synced-state
Please comment on the article so I can improve it and fix any mistakes I made. Thanks in advance! 🙂
Feel free to follow me on other platforms as well
More from Akashdeep Patra
Custom React hook to sync state with the URL #javascript #typescript #react #webdev
10 Tips for Mastering TypeScript Generics #typescript #softwareengineering #webdev #javascript
Things about typescript you should know as a pro React dev #webdev #react #typescript #javascript
Top comments (0)