Guides

Collaborative editing with Yjs

@afterrealism/dendri-y bridges a Yjs document over a Dendri room. Yjs gives you conflict-free merging (CRDTs); Dendri gives you the peer-to-peer transport. Together: Google-Docs-style collaboration with no document server.

When you need a CRDT

Topics and broadcasts (previous chapters) are great for events. But when several people edit the same data — a text document, a todo list, a canvas of shapes — concurrent edits conflict, and last-write-wins destroys work. Yjs models the data as a CRDT so every peer converges to the same result regardless of message order, offline edits, or who joined when.

Install

npm install @afterrealism/dendri-client @afterrealism/dendri-y yjs y-protocols
Peer dependencies matter here. yjs, y-protocols, and @afterrealism/dendri-client are peer dependencies of dendri-y on purpose: Yjs requires a single instance across your app. Two copies of Yjs silently break CRDT identity, and two copies of the Dendri client double your WebSocket connections. Install all four at the app level, exactly as above.

Basic setup

import { createDendriStore } from "@afterrealism/dendri-client";
import { DendriYjsProvider } from "@afterrealism/dendri-y";
import * as Y from "yjs";
import { Awareness } from "y-protocols/awareness";

// 1. A normal Dendri room store (see Configuration for options).
const room = createDendriStore({
	url: "wss://signal.example.com",
	enableRelay: true,
	fetchTurnCredentials: true,
});

// 2. The shared document + optional awareness (cursors, selections, names).
const doc = new Y.Doc();
const awareness = new Awareness(doc);

// 3. Bridge them over the room's reserved "__yjs" topic.
const provider = new DendriYjsProvider({ room, doc, awareness });

// 4. Join. Sync starts automatically as peers arrive.
room.join("document-42");

// …later, during app cleanup — order matters:
provider.destroy();
room.destroy();
doc.destroy();

That's the entire integration. When a peer joins, the provider exchanges state vectors (SyncStep1/SyncStep2), catches the newcomer up, and then streams incremental updates. Yjs updates are idempotent and commutative, so no extra delivery guarantees are needed.

Working with shared types

Everything below is standard Yjs — Dendri is just the transport. A collaborative todo list:

const todos = doc.getArray("todos");

// Local edits — automatically replicated to every peer.
function addTodo(text: string) {
	todos.push([{ text, done: false, id: crypto.randomUUID() }]);
}

function toggleTodo(index: number) {
	const item = todos.get(index);
	doc.transact(() => {
		todos.delete(index, 1);
		todos.insert(index, [{ ...item, done: !item.done }]);
	});
}

// Remote (and local) changes — re-render from the shared state.
todos.observe(() => {
	renderList(todos.toArray());
});

For rich text, bind doc.getText("content") to an editor with the standard Yjs bindings:

EditorBinding
CodeMirror 6y-codemirror.next
ProseMirror / Tiptapy-prosemirror
Quilly-quill
Monacoy-monaco
import { EditorView, basicSetup } from "codemirror";
import { yCollab } from "y-codemirror.next";

const ytext = doc.getText("content");

new EditorView({
	doc: ytext.toString(),
	extensions: [basicSetup, yCollab(ytext, awareness)],
	parent: document.querySelector("#editor"),
});

Awareness: cursors, selections, names

Awareness is Yjs's ephemeral side channel — per-peer state that isn't part of the document and disappears when a peer leaves. It's ideal for cursors and "who's viewing". The cursors demo is built entirely on it:

// Publish my cursor (throttled — see the cursors walkthrough).
awareness.setLocalStateField("user", { name: "Ana", color: "#22c55e" });

window.addEventListener("pointermove", (e) => {
	awareness.setLocalStateField("cursor", {
		x: e.clientX / innerWidth,
		y: e.clientY / innerHeight,
	});
});

// Render everyone else's cursor.
awareness.on("change", () => {
	for (const [clientId, state] of awareness.getStates()) {
		if (clientId === awareness.clientID) continue;
		if (state.cursor) drawCursor(clientId, state.cursor, state.user);
	}
});

The provider removes remote awareness states automatically when peers leave the room, so stale cursors clean themselves up.

Wire format

The provider reserves the __yjs topic and frames every message as one header byte followed by raw Yjs bytes (sent with broadcastBinary):

ByteTypePayload
0SyncStep1Y.encodeStateVector(doc)
1SyncStep2Y.encodeStateAsUpdate(doc, remoteVector)
2UpdateIncremental update from doc.on("update")
3AwarenessencodeAwarenessUpdate(awareness, ids)

On peer-join the new peer sends SyncStep1, remotes respond with SyncStep2, and both sides stream Update frames thereafter. Don't publish your own messages on the __yjs topic.

Persistence and offline

Dendri is transport, not storage — when the last peer leaves, the document lives only in their browser. For durability, combine the provider with a local persistence layer like y-indexeddb; multiple providers on one doc compose cleanly:

import { IndexeddbPersistence } from "y-indexeddb";

const local = new IndexeddbPersistence("document-42", doc); // load + autosave locally
const provider = new DendriYjsProvider({ room, doc, awareness }); // sync with peers

Requirements