Guides

Tutorial: collaborative editing

A shared board with items, live field edits, drag holds and cursors. Merges, versions, short-lived states and the activity feed.

Collaborative tools — boards, whiteboards, forms, tier lists — share one pattern: items are states, edited by many people at once, with ephemeral signals (cursors, "Ana is dragging this") around them. This tutorial builds a kanban-style board. Thuum Board and Thuum Tier List among the example apps use this design.

Topics#

DataBrokerTopicKey / typeLifetime
Board settingsState Brokerboard:{id}metalong
ItemsState Brokerboard:{id}item:{itemId}long
Drag holdsState Brokerhold:{id}item:{itemId}seconds
CursorsState Brokercursor:{id}{clientId}seconds
Activity feedEvent Brokerboard:{id}item.created, item.moved, …days

Retention and size are per Main Topic, so the ephemeral data gets its own Main Topics (hold, cursor) with short TTLs, while board keeps items for as long as you need:

TypeScript
await acme.stateBroker.topics.put('board', { ttl_seconds: 30 * 86_400 });
await acme.stateBroker.topics.put('hold', { ttl_seconds: 60 });
await acme.stateBroker.topics.put('cursor', { ttl_seconds: 60, max_states: 10_000 });
await acme.eventBroker.topics.put('board', { ttl_seconds: 7 * 86_400, max_events: 50_000 });

The browser key's State Broker claim grants P S R on board, hold and cursor; its Event Broker claim P S on board.

Loading a board#

Subscribe to the three State Broker topics on one socket, then read the current states (from your server, which lists board:{id} with a backend key). Keep the highest version per key.

TypeScript
const sb = new StateBrokerSocket(grant);
sb.onChange(apply);
await sb.ready;
await Promise.all([sb.sub(`board:${id}`), sb.sub(`hold:${id}`), sb.sub(`cursor:${id}`)]);

for (const s of await fetch(`/api/boards/${id}/states`).then((r) => r.json())) apply(s);

Editing fields without losing anyone's change#

Use merge with only the fields that changed. The server applies merges atomically, so Ana moving an item while Bo renames it gives one item with both changes:

TypeScript
// Ana drops the card in another column:
await sb.merge(`board:${id}`, `item:${itemId}`, { column: 'doing', order: 2.5 });

// At the same time, Bo edits its title:
await sb.merge(`board:${id}`, `item:${itemId}`, { title: 'Fix login on Safari' });

Two people editing the same field is last-writer-wins. For free text, merge as the user types, throttled (every 250 ms, say), so others see the text appear and conflicts stay small.

Showing who holds what#

There are no locks or conditional writes. Show intent instead, with a hold state that everyone sees:

TypeScript
// On grab: announce the hold, refreshed while dragging.
await sb.set(`hold:${id}`, `item:${itemId}`, { by: clientId, name, color, x, y });
// While dragging, about 15 times a second at most:
await sb.merge(`hold:${id}`, `item:${itemId}`, { x, y });
// On drop or cancel:
await sb.del(`hold:${id}`, `item:${itemId}`);

Other clients render the card under the holder's pointer and avoid grabbing it. Because TTL removal doesn't notify, each client also ignores holds not updated for 5 seconds: a holder whose tab crashed doesn't keep the card forever.

Cursors#

The same idea, one state per client:

TypeScript
let pending: { x: number; y: number } | null = null;

board.addEventListener('pointermove', (e) => {
	pending = { x: e.offsetX, y: e.offsetY };
});
setInterval(() => {
	if (!pending) return;
	void sb.set(`cursor:${id}`, clientId, { name, color, ...pending }).catch(() => {});
	pending = null;
}, 66); // ~15 updates a second, only when the pointer moved

addEventListener('pagehide', () => void sb.del(`cursor:${id}`, clientId));

Hide cursors not updated for a few seconds. With dozens of collaborators, set beats merge here: the value is small and fully replaced each time.

The activity feed#

After each meaningful state write, publish an event describing it. Events give late joiners a history ("Bo moved Fix login to Doing, 2 min ago") that states can't, since states keep only the current value.

TypeScript
await sb.merge(`board:${id}`, `item:${itemId}`, { column: 'done' });
await eb.pub(`board:${id}`, 'item.moved', { by: clientId, name, item: itemId, to: 'done' });

Write the state first: the state is the truth, the event is the story.

Validate everything#

Any client with P on board can write any value to any key there. Validate each state before rendering it (known keys, expected types, sizes), ignore what doesn't fit, and render text as text. If some changes must be enforced (permissions per board, workflows), have clients send them to your server, which validates and writes with a backend key, and give browsers only S R on board.