esc

Type to search...

Space

A Space is the surface where teammates and agents collaborate side by side: shared context, shared documents, shared conversation. This demo strips it to the realtime essentials: messages, presence, typing.

  • Subscriptions — every component that needs space data calls one centralised hook. The library ref counts; one wire frame regardless of how many components render.
  • Acked sends — every message carries an ack id so the library can clear in-flight tracking when the server confirms.
  • Presence + typing — secondary signals routed through the same wire, written into separate store slices. Components subscribe to whichever slice they render.

Live demo

Switch spaces to see joining state. Type to trigger Ada's typing indicator. Send to get Orion's reply. Try opening this page in multiple tabs — each is a fresh client; the same wire patterns apply.

#space:design-review

idle

Say hi. Orion (the agent) will reply, and Ada types back when you do.

Centralised subscription hook

Every consumer in the Space tree uses one hook. The key, subscribe payload, and unsubscribe payload all derive from the same spaceId argument — impossible for two callers to drift on params.

useSpaceSubscription.ts
function useSpaceSubscription(spaceId: string) {
  useSocketSubscription(manager, {
    key: spaceId,
    subscribe: { type: "subscribe", channel: spaceId },
    unsubscribe: { type: "unsubscribe", channel: spaceId },
  });
}

Three signals, one bridge

SpaceBridge.tsx
function SpaceBridge() {
  useSocketEvent(manager, "chat", (msg) => {
    appendMessage(msg.channel, {
      id: msg.id,
      sender: msg.sender,
      senderKind: msg.senderKind,
      text: msg.text,
    });
  });
  useSocketEvent(manager, "presence", (msg) => {
    setPresence(msg.channel, msg.members);
  });
  useSocketEvent(manager, "typing", (msg) => {
    setTyping(msg.channel, msg.userId, msg.name, msg.active);
  });
  return null;
}

Mounted once at the root of the Space tree. The bridge only writes to the store; reads happen in the components that render each slice. Tokens streaming in does not re-render the presence pill row.

Composing reads

useSpace.ts
function useSpace(spaceId: string) {
  useSpaceSubscription(spaceId);
  const joining = useSocketPendingSubscription(manager, spaceId);
  const messages = useStore((s) => s.messages[spaceId] ?? EMPTY_MESSAGES);
  const presence = useStore((s) => s.presence[spaceId] ?? EMPTY_MEMBERS);
  const typing = useStore((s) => s.typing[spaceId] ?? EMPTY_TYPING);
  return { joining, messages, presence, typing };
}

The EMPTY_* stable references are the only zustand-with-React subtlety: same identity every render, so downstream useEffects and memos stay stable while the channel is empty.

See Subscriptions for the full ref-count + reconnect story, and Space persistence for what happens to a Space message when the socket drops mid-flight.