Skip to main content

Messages & presence

Shared state is for what something is; messages are for what just happened. In this guide we'll build the two features that teach the difference: a cart badge that every tab keeps current, and a "who else is here" strip. Along the way you'll hit the one behavior that surprises everyone once — no echo — and see why it's a feature.

Declare the contract once

A channel is typed by a message map — event names to payload shapes. Declare it once, bind it to a name with defineChannel, and every tab speaks the same language with nothing to repeat at call sites:

shop-channel.ts
import { defineChannel } from 'use-everywhere';

export type ShopEvents = {
'cart-updated': { items: number };
'logged-out': undefined; // no payload
};

export const shop = defineChannel<ShopEvents>('shop');

Build the cart badge

CartBadge.tsx
import { useState } from 'react';
import { shop } from './shop-channel';

function CartBadge() {
const [items, setItems] = useState(0);
const send = shop.useSend();

// Fires when any OTHER tab posts 'cart-updated' — never for our own posts.
shop.useMessage('cart-updated', (payload) => setItems(payload.items));

const addToCart = () => {
setItems(items + 1); // 1. update this tab ourselves…
send('cart-updated', { items: items + 1 }); // 2. …then notify every other tab
};

return <button onClick={addToCart}>Cart ({items})</button>;
}

Two lines deserve a closer look.

The two-step in addToCart is the no-echo idiom. Messages are never delivered back to the sender — same as raw BroadcastChannel, but documented instead of surprising. So the sending tab updates itself explicitly, then announces. If that ceremony feels wrong for your case, that's the signal the value should be shared state instead, where one setter updates every tab including yours.

The handler closes over items safely. shop.useMessage keeps your handler fresh across renders without resubscribing — no stale-closure bugs, no effect churn. Details in useMessage, which it delegates to.

Still the litmus test

A tab opened after cart-updated fired never hears it. Here that's fine — the badge is display sugar, and the cart's truth lives on the server. If a late joiner must know, it's state, not a message.

Prefer not to bind at module level? The standalone useChannel / useMessage / useSend hooks are the same machinery — defineChannel is sugar over them.

Show who else is here

Presence answers "who else has this open?" with zero setup — every bus already heartbeats:

PresenceStrip.tsx
import { usePeers, useClientId } from 'use-everywhere';

function PresenceStrip() {
const peers = usePeers(); // everyone except me; re-renders on join/leave only
const me = useClientId();
return (
<p>
me: {me.slice(0, 6)} · also here:{' '}
{peers.map((p) => `${p.kind} ${p.id.slice(0, 6)}`).join(', ') || 'nobody'}
</p>
);
}

Open a second tab and it appears in the list within a heartbeat (≤2s, instantly if it says hello). Close it and it disappears immediately — closing tabs announce themselves on pagehide. Kill it hard (crash, task manager) and it lingers for ~5 seconds before being pruned. Any traffic — a state patch, an event — also counts as proof of life, so busy tabs never flicker offline.

Each peer is { id, kind, lastSeen }, and kind distinguishes 'tab' from 'worker' — which is how the demo app renders workers as square dots.

Combine them: react to who did what

Every message handler receives a meta argument with the sender's clientId — the same id presence shows for that tab. That's one identity across features, and it lets you write UI like this:

shop.useMessage('logged-out', (_payload, meta) => {
toast(`Signed out by tab ${meta.clientId.slice(0, 6)}`);
window.location.assign('/login');
});

Where to next