PlayMesh/Docs
HomeGitHubnpm

Using with React

A shared client, event subscriptions with cleanup, and live presence/state hooks.

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.