One client, shared
Create the client once in a module and import it everywhere - not inside a component, where re-renders would recreate it.
◆lib/playmesh.ts
import { PlayMeshClient } from '@playmesh/client';
export const client = new PlayMeshClient({
url: import.meta.env.VITE_SERVER_URL,
auth: async () => ({ token: await getAccessToken() }),
});
export const ready = client.connect(); // one connection for the whole app
Subscribing to events
Every listener API returns an unsubscribe function, so subscribing in an effect and returning it as cleanup is all you need - handlers never stack up across re-mounts.
function useServerEvent(event: string, handler: (payload: unknown) => void) {
useEffect(() => client.on(event, handler), [event, handler]);
}
// In a component
useServerEvent('world-event', payload => setWorldEvent(payload));
Live presence and state
onPresence and onStateChange follow the same pattern - subscribe in the effect, return the unsubscribe. presenceOf and stateOf return a fresh object on every call, so setting them into React state always triggers a re-render.
function usePresence(instancePath: string) {
const [presence, setPresence] = useState(() => client.presenceOf(instancePath));
useEffect(() => {
setPresence(client.presenceOf(instancePath)); // sync in case it changed before the effect ran
return client.onPresence(event => {
if (event.instance === instancePath) setPresence(client.presenceOf(instancePath));
});
}, [instancePath]);
return presence; // { count, users } | undefined
}
function useInstanceState(instancePath: string) {
const [state, setState] = useState(() => client.stateOf(instancePath));
useEffect(() => {
setState(client.stateOf(instancePath));
return client.onStateChange(change => {
if (change.instance === instancePath) setState(client.stateOf(instancePath));
});
}, [instancePath]);
return state; // public state replica
}
Switching worlds
The server decides where a player is - it can move them with session.move() at any time. Track the current world from onJoin/onLeave rather than from your own navigation, so server moves and reconnects just work.
function useCurrentWorld(domainId: string) {
const inDomain = (path: string) => path.startsWith(domainId + '/');
const [world, setWorld] = useState(() => client.instances.find(inDomain));
useEffect(() => {
setWorld(client.instances.find(inDomain));
const subs = [
client.onJoin(path => { if (inDomain(path)) setWorld(path); }),
client.onLeave(path => setWorld(current => (current === path ? undefined : current))),
];
return () => subs.forEach(unsubscribe => unsubscribe());
}, [domainId]);
return world; // e.g. 'world/caves' - its state is already in client.stateOf(world)
}
function useRoles() {
const [roles, setRoles] = useState(() => client.session?.roles ?? []);
useEffect(() => client.onRolesChange(setRoles), []);
return roles; // updates live when the server grants or revokes a role
}
Connection status UI
function useConnectionStatus() {
const [status, setStatus] = useState<'connected' | 'reconnecting' | 'kicked'>('connected');
useEffect(() => {
const subs = [
client.onDisconnect(() => setStatus('reconnecting')),
client.onReconnect(() => setStatus('connected')),
client.onKick(() => setStatus('kicked')),
];
return () => subs.forEach(unsubscribe => unsubscribe());
}, []);
return status;
}
ℹBefore PlayMesh 0.6, lifecycle handlers were add-only and had to live at module scope. Since 0.6 every listener API returns an unsubscribe function, so all of them can be registered (and cleaned up) inside effects.