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:
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
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.
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:
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
defineChannel— the bound hooks used on this page.useChannel,useMessage,useSend— the standalone trio underneath.usePeers/useClientId— everything presence.- Log out everywhere — this page's patterns as a lift-in recipe.