DEV Community

Cover image for Let's build a custom React hook for cross-tab state synchronization
Akashdeep Patra
Akashdeep Patra

Posted on AI-assisted

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

Contents

  1. The Problem
  2. Architecture Overview
  3. The Types & Interface
  4. Tab Identity & State Ref
  5. The BroadcastChannel Listener
  6. The Synchronized Mutation Function
  7. Lock Coordination & Broadcasting
  8. Graceful Degradation
  9. Key Architectural Safeguards
  10. Real-World Use Cases
  11. 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
Enter fullscreen mode Exit fullscreen mode

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})      │
  └───────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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.

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]);
Enter fullscreen mode Exit fullscreen mode

Three things to note here:

  1. We check for BroadcastChannel support — if it doesn't exist (like in SSR), we early-return and the channel ref stays null.
  2. The senderId checkdata.senderId === tabIdRef.current ensures we never process our own messages. This eliminates echo loops entirely.
  3. Cleanup on unmount — the useEffect return 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]
);
Enter fullscreen mode Exit fullscreen mode

Let me break down what happens inside updateFn:

  1. Functional updaters work tootypeof 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.
  2. Optimistic updatesetState(nextState) fires immediately so the UI updates without waiting for cross-tab coordination.
  3. BroadcastchannelRef.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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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 ✓
Enter fullscreen mode Exit fullscreen mode

The lock ensures they resolve one after the other. Tab A acquires the lock, reads 0, writes 1, broadcasts. Then Tab B acquires the lock, reads 1, writes 2, broadcasts. No split-brain mutations.

The lock name is derived from the channel name:

const lockName = `lock:${channelName}`;
Enter fullscreen mode Exit fullscreen mode

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 BroadcastChannel doesn't exist, the useEffect early-returns and the channel ref stays null — no error thrown
  • If navigator.locks doesn't exist, setSyncedState falls back to calling updateFn() directly — no error thrown
  • If crypto isn't available, we fall back to Math.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 read 0, both write 1, and you'd end up with 1 instead of 2. 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
Enter fullscreen mode Exit fullscreen mode

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 senderId matches 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 stale prev value. stateRef.current always 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)