Warning
This is an internal project, and is not intended for public use. No support or stability guarantees are provided.
The useCrossTabState hook is a useState whose value is mirrored across same-origin browser tabs and windows of the same page, using a BroadcastChannel. Set it in one tab and the others follow — for example, editing a live demo in a Chrome split view and watching both panes stay in sync.
Unlike useLocalStorageState, it is ephemeral: nothing is persisted. State lives only in the open tabs, and a tab opened mid-session catches up from a peer that is already holding it rather than from storage.
useState — returns [value, setValue], with lazy initializers and functional updates.null to turn syncing off (also SSR-safe; no channel opens on the server).useCrossTabState(key, initialValue) works exactly like useState(initialValue), with one extra argument: the channel key. Tabs sync only when their keys match.
Both panes below share one channel key, so editing either keeps them in sync as you type. A BroadcastChannel reaches sibling instances on the same page, so this works without two tabs — but open this page in a second tab or split view to see it sync across tabs too.
'use client';
import * as React from 'react';
import { useCrossTabState } from '@mui/internal-docs-infra/useCrossTabState';
import styles from './SyncedPanes.module.css';
function SyncedEditor({ label }: { label: string }) {
// Every editor sharing this key — both panes here, and any other tab of this page
// — reads and writes the same value, so editing one updates the rest.
const [text, setText] = useCrossTabState('use-cross-tab-state-demo', 'Edit me…');
return (
<label className={styles.pane}>
<span className={styles.label}>{label}</span>
<textarea
className={styles.input}
value={text}
onChange={(event) => setText(event.target.value)}
rows={4}
aria-label={label}
/>
</label>
);
}
export function SyncedPanes() {
return (
<div className={styles.container}>
<SyncedEditor label="Pane A" />
<SyncedEditor label="Pane B" />
</div>
);
}
'use client';
import { useCrossTabState } from '@mui/internal-docs-infra/useCrossTabState';
function SharedNotepad() {
const [text, setText] = useCrossTabState('notepad', '');
return <textarea value={text} onChange={(event) => setText(event.target.value)} />;
}
Open the page in two tabs and type in one — the other updates as you go. Open a third tab and it resumes the current text.
Both work just like useState. A functional update is broadcast by its resolved value.
function Counter() {
const [count, setCount] = useCrossTabState('counter', () => 0);
return <button onClick={() => setCount((current) => current + 1)}>{count}</button>;
}
Pass null as the key to disable syncing. The hook then behaves like a plain useState, so you can make it conditional without breaking the rules of hooks.
function SharedNotepad({ sync }: { sync: boolean }) {
const [text, setText] = useCrossTabState(sync ? 'notepad' : null, '');
return <textarea value={text} onChange={(event) => setText(event.target.value)} />;
}
The key is the channel name, so include a per-instance id (and often the page path) when several independent widgets could otherwise collide.
function SharedNotepad({ id }: { id: string }) {
const [text, setText] = useCrossTabState(`notepad:${window.location.pathname}:${id}`, '');
return <textarea value={text} onChange={(event) => setText(event.target.value)} />;
}
When the state doesn't live in a useState — it comes from a reducer, an external store, or a controller with its own setter — use the lower-level useCrossTabMirror(key, value, applyRemote). It doesn't own any state; it broadcasts the value you pass whenever it changes, and calls applyRemote with values received from other tabs. (useCrossTabState is a thin wrapper over it: a useState plus useCrossTabMirror(key, value, setValue).)
import { useCrossTabMirror } from '@mui/internal-docs-infra/useCrossTabState';
function Counter() {
const [count, dispatch] = React.useReducer(reducer, 0);
// Broadcast `count`, and apply a peer's value by dispatching into the reducer.
useCrossTabMirror('counter', count, (remote) => dispatch({ type: 'set', value: remote }));
return <button onClick={() => dispatch({ type: 'increment' })}>{count}</button>;
}
Reach for it only when the state genuinely can't live in the hook — most demos and widgets are better served by useCrossTabState above, which owns the state for you.
On every local change to the value, the hook posts it on the channel. When a message arrives from another tab, it applies the value and remembers it — so the resulting local change isn't re-broadcast. That one-step guard is what stops two tabs from bouncing the same value back and forth. There is no merge or conflict resolution: last write wins.
Broadcasts are fire-and-forget, so a tab opened after a change would otherwise start at its initial value. To avoid that, when a tab mounts it sends a request on the channel, and any tab that already holds shared state replies with the current value, which the newcomer adopts.
A tab is considered to hold shared state once it has either broadcast a local change or adopted a peer's value. This has two consequences:
Catch-up survives the original editor closing: any remaining holder serves the next tab to join.
When key is null, or BroadcastChannel isn't available (server render, older runtimes), no channel opens and the hook behaves like a plain useState.
useCrossTabStateA useState whose value is mirrored across same-origin browser tabs/windows of the
same page through a BroadcastChannel — set it in one tab and the others follow.
For example, edit a live demo in a Chrome split view and both panes stay in sync.
Drop-in for useState: returns [value, setValue] and supports lazy initializers
and functional updates. The extra key is the channel name — tabs sync only when
their keys match, and null disables syncing (also SSR-safe; no channel opens on
the server). State is EPHEMERAL: nothing is persisted, but a tab opened mid-session
catches up from a peer that already holds it instead of starting at initialValue.
For state that must survive a reload, use useLocalStorageState instead.
value must be structured-cloneable (plain data) — postMessage clones it. When the
state lives elsewhere (a reducer, a store, a controller) and you only want to sync
it, use the lower-level useCrossTabMirror instead.
| Parameter | Type | Description |
|---|---|---|
| key | | The channel name; tabs sync only when their keys match. |
| initialValue | | The initial state, or a lazy initializer — exactly like |
[T, React.Dispatch<React.SetStateAction<T>>]useCrossTabMirrorMirrors a value you already own across same-origin browser tabs/windows through a
BroadcastChannel — so, for example, two Chrome split-view tabs of the same page
keep a live demo’s edits in sync. The lower-level building block behind
useCrossTabState: use it when the state lives elsewhere (a reducer, a store, a
controller’s own useState) and you only want to keep it in sync; reach for
useCrossTabState when you just want a synced useState.
Pass a key (the channel name) that is identical across the tabs meant to share
state, and null to disable — no channel is opened, which also makes it SSR-safe
and gives the caller a clean off switch. value is broadcast whenever it changes
locally; an incoming remote value is handed to applyRemote WITHOUT being echoed
back, so two tabs can’t ping-pong. Last write wins.
A tab opened mid-edit catches up: on mount it asks peers for the current state, and any tab already holding shared state replies. A fresh tab with nothing to share stays quiet, so it can never blank out the asker; and once a tab holds shared state it ignores further catch-up replies, keeping its own value authoritative.
value must be structured-cloneable (plain data) — postMessage clones it.
| Parameter | Type | Description |
|---|---|---|
| key | | The channel name; tabs sync only when their keys match. |
| value | | The current value, broadcast to other tabs whenever it changes. |
| applyRemote | | Called with a value received from another tab — apply it to your own state. |
voidThe value/applyRemote signature of the lower-level useCrossTabMirror is shown in Mirroring state you already own above.
For state that must persist across reloads or sessions, reach for useLocalStorageState (which also syncs across tabs, via storage events) instead.
BroadcastChannel doesn't cross origins.postMessage, so it can't contain functions, DOM nodes, or React elements.useLocalStorageState — persistent state that also syncs across tabs.useDemoController — uses useCrossTabState to keep live-demo edits in sync across split-view tabs.