Skip to content

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:

  1. The new tab posts a state-request with a unique request ID.
  2. Existing tabs answer with state-response, including that ID and their current value.
  3. 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 set or 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.