Back to News & Insights
JavaScript September 9, 2026 ยท 9 min read

Let's build a custom React hook for cross-tab state synchronization

Contents The Problem Architecture Overview The Types & Interface Tab Identity...

Let's build a custom React hook for cross-tab state synchronization

[](#contents)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

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.

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? ๐ŸŽฏ

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.

The hook combines two browser APIs into a single cohesive abstraction: 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. 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. 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. 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.

First, let's define the types our hook needs. We need an options interface for customization, and a message type for our BroadcastChannel communication.

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.

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.

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.

This is critical for fast-succession writes. When you call setCount((prev) => prev + 1) rapidly across tabs, stateRef.current always points to the latest value, not a captured snapshot from when the callback was created.

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.

Three things to note here: We check for BroadcastChannel support โ€” if it doesn't exist (like in SSR), we early-return and the channel ref stays null. The senderId check โ€” data.senderId === tabIdRef.current ensures we never process our own messages. This eliminates echo loops entirely. Cleanup on unmount โ€” the useEffect return closes the channel so we don't leak browser IPC resources.

This is the core of the hook. The setSyncedState function is what replaces setState. It's async because it needs to coordinate across tabs.

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 against stateRef.current to 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.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation