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.
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).
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.
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.
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
- Live notifications — hook-first
useTopicpatterns for notifications and signaling - Offline-first apps — what happens to collaborative edits when a user goes offline
- Search & live queries — filter and sort the shared map with reactive predicate queries