Skip to content

Tutorial: Build the Counter Demo

This tutorial builds a small but complete demo app: a counter that syncs across tabs, a message log, and a panel that lists the other open tabs. The finished app is a compact version of the hook demo, so when you are done you can compare yours with the real thing.

Terminal window
npm create vite@latest counter-demo -- --template react-ts
cd counter-demo
npm install react-broadcast-sync
npm run dev

Open the printed URL in two tabs side by side. Everything below assumes both are open.

Replace App.tsx with:

import { useBroadcastChannel } from 'react-broadcast-sync';
export default function App() {
const counter = useBroadcastChannel('counter', { namespace: 'tutorial' });
return <main>Open tabs: check the console</main>;
}

Nothing visible yet. The namespace keeps the tutorial’s traffic away from any other counter channel on the same origin.

Keep the count locally, send every change, and apply what arrives:

import { useEffect, useState } from 'react';
import { useBroadcastChannel } from 'react-broadcast-sync';
export default function App() {
const [count, setCount] = useState(0);
const { messages, postMessage } = useBroadcastChannel('counter', {
namespace: 'tutorial',
keepLatestMessage: true, // only the newest value matters
});
useEffect(() => {
const latest = messages[messages.length - 1];
if (latest && typeof latest.message === 'number') setCount(latest.message);
}, [messages]);
const update = (next: number) => {
setCount(next);
postMessage('set', next);
};
return (
<main>
<button onClick={() => update(count - 1)}>-</button>
<strong>{count}</strong>
<button onClick={() => update(count + 1)}>+</button>
</main>
);
}

Click + in one tab: the other jumps to the same number. Note where postMessage is called: in the click handler, not in the effect. Sending from the effect would rebroadcast every received value (see Sync shared state).

Give the tab a name and add a ping button:

const { ping, isPingInProgress } = useBroadcastChannel('counter', {
namespace: 'tutorial',
keepLatestMessage: true,
sourceName: 'Tutorial tab',
});
const [others, setOthers] = useState<string[]>([]);
<button disabled={isPingInProgress} onClick={async () => setOthers(await ping(300))}>
{isPingInProgress ? 'Pinging...' : 'Find open tabs'}
</button>
<ul>{others.map(name => <li key={name}>{name}</li>)}</ul>

In a real app each tab needs a different name; hardcoding one name in both tabs makes them ignore each other (see Tabs and identity).

Open a third tab: its counter starts at 0 while the others show a higher number. Add the request/answer pattern from Late-joining tabs:

Add onMessage to the options of the hook call from step 4 (one hook call per channel, so extend the existing one):

const countRef = useRef(count);
countRef.current = count;
const { messages, postMessage, ping, isPingInProgress } = useBroadcastChannel('counter', {
namespace: 'tutorial',
keepLatestMessage: true,
sourceName: 'Tutorial tab',
onMessage: { 'state-request': () => postMessage('set', countRef.current) },
});
useEffect(() => {
postMessage('state-request', null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

Refresh the third tab: it asks, an older tab answers, and the counts match.

The full hook demo adds three channels (counter, text, todo), per-tab generated names, and a message log, all built from the same pieces you just used. Its source is in the repository if you want to keep going.