DocsGuidesReal-time collaboration

Real-time collaboration

Real-time collaboration: each user writes to their own key in a shared map; conflicts resolve automatically via HLC timestamps; transient events (cursor positions, typing indicators) go through topics for ephemeral broadcast. The result is Google Docs-style multi-user editing without a backend conflict-resolution layer.


Live cursors

Cursor positions are ephemeral — you don’t need to store them. Use useTopic to broadcast and receive cursor positions in real time.

components/CollaborativeCanvas.tsx
import { useTopic } from '@topgunbuild/react';
import { useState, useCallback } from 'react';

interface CursorPosition {
  userId: string;
  x: number;
  y: number;
}

export function CollaborativeCanvas() {
  const [cursors, setCursors] = useState<Record<string, CursorPosition>>({});

  // Receive cursor updates from other users
  const cursorTopic = useTopic('cursors:doc-1', (msg) => {
    const position = msg as CursorPosition;
    setCursors(prev => ({ ...prev, [position.userId]: position }));
  });

  const handleMouseMove = useCallback((e: React.MouseEvent) => {
    cursorTopic.publish({
      userId: 'user-alice',
      x: e.clientX,
      y: e.clientY,
    });
  }, [cursorTopic]);

  return (
    <div onMouseMove={handleMouseMove} style={{ position: 'relative', width: '100%', height: '400px' }}>
      {Object.values(cursors).map(cursor => (
        <div
          key={cursor.userId}
          style={{ position: 'absolute', left: cursor.x, top: cursor.y }}
          className="cursor-indicator"
        >
          {cursor.userId}
        </div>
      ))}
    </div>
  );
}

Topics are ephemeral — if a user is offline when a cursor event fires, they miss it. That’s the right tradeoff for cursor positions: you always want the latest value, not a backlog of stale positions.


Live edits

For persistent shared state (document text, shared notes, task lists), use a shared map. Each user writes to their own key or a shared key. Concurrent writes to the same key resolve automatically: the write with the highest HLC timestamp wins (Last-Write-Wins semantics).

components/CollaborativeDocument.tsx
import { useQuery, useMutation } from '@topgunbuild/react';

interface DocumentBlock {
  author: string;
  content: string;
  updatedAt: number;
}

export function CollaborativeDocument({ docId }: { docId: string }) {
  const mapName = `doc:${docId}`;

  // Subscribe to all blocks in the document — updates push in real time
  const { data: blocks, loading } = useQuery<DocumentBlock>(mapName, {
    sort: { updatedAt: 'asc' },
  });

  // Mutations apply locally first, sync to server in the background
  const { update } = useMutation<DocumentBlock>(mapName);

  const handleEdit = (blockKey: string, content: string) => {
    update(blockKey, { content, updatedAt: Date.now() });
  };

  if (loading) return <p>Loading document...</p>;

  return (
    <div>
      {blocks.map(block => (
        <textarea
          key={block._key}
          defaultValue={block.content}
          onBlur={(e) => handleEdit(block._key, e.target.value)}
        />
      ))}
    </div>
  );
}

Writes apply locally before hitting the network, so edits feel instant. When two users edit the same block at the same time, the server merges them using HLC timestamps — no manual conflict handling required.


Presence indicators

Presence (who is online, who is typing) is ephemeral. Use useTopic with a heartbeat pattern: each client publishes its presence on an interval; subscribers expire entries after a timeout.

components/PresenceIndicators.tsx
import { useTopic } from '@topgunbuild/react';
import { useState, useEffect } from 'react';

interface PresencePayload {
  userId: string;
  status: 'online' | 'typing';
  timestamp: number;
}

const PRESENCE_TTL_MS = 5000; // expire after 5 s without heartbeat

export function PresenceIndicators({ roomId, currentUserId }: {
  roomId: string;
  currentUserId: string;
}) {
  const [online, setOnline] = useState<Record<string, PresencePayload>>({});

  const presenceTopic = useTopic(`presence:${roomId}`, (msg) => {
    const payload = msg as PresencePayload;
    setOnline(prev => ({ ...prev, [payload.userId]: payload }));
  });

  // Broadcast own presence every 3 s
  useEffect(() => {
    const interval = setInterval(() => {
      presenceTopic.publish({ userId: currentUserId, status: 'online', timestamp: Date.now() });
    }, 3000);
    return () => clearInterval(interval);
  }, [presenceTopic, currentUserId]);

  // Expire stale entries every second
  useEffect(() => {
    const interval = setInterval(() => {
      const cutoff = Date.now() - PRESENCE_TTL_MS;
      setOnline(prev => {
        const next: Record<string, PresencePayload> = {};
        for (const [id, p] of Object.entries(prev)) {
          if (p.timestamp > cutoff) next[id] = p;
        }
        return next;
      });
    }, 1000);
    return () => clearInterval(interval);
  }, []);

  const others = Object.values(online).filter(p => p.userId !== currentUserId);

  return (
    <div className="presence-bar">
      {others.map(p => (
        <span key={p.userId} className="avatar" title={p.userId}>
          {p.userId[0].toUpperCase()}
        </span>
      ))}
    </div>
  );
}

Topics are at-most-once

Topics deliver messages to currently-connected subscribers only. If a client is offline when a presence heartbeat fires, they miss it — and the TTL expiry logic above handles the cleanup on reconnect.


Non-React contexts

Outside React (Node.js scripts, service workers, integration tests), use the imperative client API directly.

src/worker.ts
import { TopGunClient } from '@topgunbuild/client';
import { IDBAdapter } from '@topgunbuild/adapters';

const client = new TopGunClient({
  serverUrl: 'ws://localhost:8080',
  storage: new IDBAdapter(),
});

await client.start();

// Shared map — persistent, CRDT-merged
const docMap = client.getMap('doc:room-1');

docMap.set('block-1', {
  author: 'alice',
  content: 'Hello world',
  updatedAt: Date.now(),
});

// Topic — ephemeral broadcast
const cursorTopic = client.topic('cursors:room-1');
cursorTopic.publish({ userId: 'alice', x: 120, y: 340 });

const unsubscribe = cursorTopic.subscribe((msg, ctx) => {
  console.log('Cursor from', ctx.publisherId, msg);
});

Next steps