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
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:
| Editor | Binding |
|---|---|
| CodeMirror 6 | y-codemirror.next |
| ProseMirror / Tiptap | y-prosemirror |
| Quill | y-quill |
| Monaco | y-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):
| Byte | Type | Payload |
|---|---|---|
0 | SyncStep1 | Y.encodeStateVector(doc) |
1 | SyncStep2 | Y.encodeStateAsUpdate(doc, remoteVector) |
2 | Update | Incremental update from doc.on("update") |
3 | Awareness | encodeAwarenessUpdate(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
@afterrealism/dendri-client≥ 2.3.7 (forroom.broadcastBinary())yjs^13.6.0y-protocols^1.0.0