Late-joining Tabs
Messages are not replayed: a tab that opens late sees only what is sent after it opened. keepLatestMessage and getLatestMessage read the local log, so they cannot recover an earlier value. A request/answer exchange can bootstrap a new tab:
- The new tab posts a
state-requestwith a unique request ID. - Existing tabs answer with
state-response, including that ID and their current value. - Only the requesting tab accepts the first matching response. If it has already received a live update or made a local edit, it ignores the bootstrap response.
Keep bootstrap responses separate from live set messages. Broadcasting a response as set makes every existing tab apply it too, and a delayed answer can undo an edit made while the request was in flight.
import { useEffect, useRef, useState } from 'react';import { useBroadcastChannel } from 'react-broadcast-sync';
export default function Counter() { const [count, setCount] = useState(0); const countRef = useRef(count); const pendingRequest = useRef<string | null>(null);
const apply = (next: number) => { countRef.current = next; setCount(next); };
const { postMessage } = useBroadcastChannel('counter', { onMessage: { set: msg => { if (typeof msg.message !== 'number') return; pendingRequest.current = null; apply(msg.message); }, 'state-request': msg => { if (typeof msg.message?.requestId !== 'string') return; postMessage('state-response', { requestId: msg.message.requestId, value: countRef.current, }); }, 'state-response': msg => { const { requestId, value } = msg.message ?? {}; if ( typeof requestId !== 'string' || requestId !== pendingRequest.current || typeof value !== 'number' ) return; pendingRequest.current = null; apply(value); }, }, });
useEffect(() => { const suffix = Math.random().toString(36).slice(2); const requestId = `${Date.now()}-${suffix}`; pendingRequest.current = requestId; postMessage('state-request', { requestId }); // Ask only on mount. /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []);
const update = (next: number) => { pendingRequest.current = null; apply(next); postMessage('set', next); };
const increment = () => update(countRef.current + 1); return <button onClick={increment}>Count: {count}</button>;}Why the ref: a request handler needs the current count. Updating countRef together with state also lets it answer correctly before React’s next render. Each hook instance gets a generated source name by default; do not give every tab the same sourceName or they will ignore each other.
Limits of this example:
- No existing tab: nothing answers, so the new tab keeps its default state. If all tabs close, their state is gone. Use persistence or a server when that is not acceptable.
- Several tabs answer: first matching answer wins. This is a simple policy, not proof that the first peer has the newest state. Tabs that disagree need a versioned protocol or an authority.
- Live edit during bootstrap: an incoming
setor local click cancels the pending bootstrap. A delayed reply cannot replace that edit. This does not make concurrent absolute-value writes atomic: two tabs incrementing from the same value can still lose an increment. - History is different: this sends one current value, not old messages. Replaying a log also needs expiry/clearing rules, deduplication, registered-type filtering and size limits.
- No automatic guarantee: requests/replies can be missed, and this example has no retry or persistence. For strict consistency use an authority such as a server rather than peer broadcast.
