Collaborative documents
A typed handle onto one collaborative record. Build it from the client you already have, connect it, and write into a field while somebody else writes into the same one.
You marked the fields in the schema. Nothing in the browser uses them yet.
Everything below runs there, and none of it starts until you call connect().
Build the CRDT client
import { createCrdtClient } from "questpie/crdt";
import { client } from "./client";
export const crdt = createCrdtClient(client);createCrdtClient takes the client you already built and reuses its realtime
session. A collaborative app still holds exactly one connection.
If that connection started for a live query without CRDT authority, the first
document connection replaces the edge once and carries its query subscriptions
onto the CRDT-capable session.
createClient() does not build this API for you. There is no client.crdt
either, and reading that property throws a message pointing you back here.
Build it once and pass it around. Each call gets its own scheduler. That scheduler bounds pulls and awareness writes across every open document.
Open a document
const article = crdt.collections.articles.document({ id: "article-1" });
await article.connect({ mode: "edit", fallback: "view" });A collection takes { id }. A global takes nothing. Every call to document()
builds a new handle. Keep the one you got. Do not build a fresh one per render.
Constructing the handle does nothing. No request, no IndexedDB. Both start at
connect(). That is what makes a handle safe to build during SSR.
mode is "view" or "edit". fallback: "view" accepts a read-only result
when edit access is refused. Without it, a refused edit rejects the whole
connection. fallback is legal only alongside mode: "edit".
No engine means no connection
connect() needs an engine. Pass it to createCrdtClient() as
runtime: { engines: { text: yjsClientEngine() } }. Leave it out and the
call rejects with CRDT_UNAVAILABLE and the state goes to denied. It also
has to match the engine the server registered.
Read and edit a field
const title = article.fields.title.text.value();
article.fields.title.text.apply([
{ type: "insert", index: title.length, value: " together" },
]);
article.fields.tags.set.add("news");
article.fields.tags.set.delete("draft");Which port a field has depends on the format you declared for it.
| Format | Call | What it does |
|---|---|---|
| text | .text.value() | the current string |
| text | .text.apply(ops) | inserts and deletes |
| text | .anchors.create(input) | a position that survives edits |
| text | .anchors.resolve(token) | where that position is now |
| set | .set.values() | every member, in byte order |
| set | .set.has(value) | membership |
| set | .set.add(value) | one add |
| set | .set.delete(value) | one remove |
| set | .set.apply(ops) | several at once |
| both | .format | "text" or "set" |
A text operation is { type: "insert", index, value } or
{ type: "delete", index, length }. Indices are UTF-16 code units and may not
split a surrogate pair. An insert value cannot be empty and cannot contain a
NUL. Anything else throws CrdtMutationError with INVALID_OPERATION.
Every field key hands back a port, whether or not that field exists. The call is
what throws. Reading before the first sync finishes throws CrdtReadError with
NOT_READY. Writing into a field you only hold view on throws
CrdtMutationError with FIELD_VIEW_ONLY. A field the server did not grant you
throws FIELD_HIDDEN.
Change several fields at once
article.transaction(({ fields }) => {
fields.title.text.apply([{ type: "insert", index: 0, value: "Shared: " }]);
fields.tags.set.add("collaboration");
fields.content.text.apply([
{ type: "insert", index: 0, value: "First paragraph." },
]);
});All three edits leave as one bundle. The server appends every part or none of them.
The callback runs immediately and must return nothing. Return a promise and it
throws ASYNC_TRANSACTION. Open a transaction inside a transaction and it
throws NESTED_TRANSACTION. The outer one is dropped too. Anything invalid
inside rejects the whole local bundle. One bundle carries at most 32 parts.
Watch the state
const unsubscribe = article.subscribe((state) => {
if (state.status === "ready") render(state.fieldGrants);
if (state.status === "recovery-required") showRecovery(state.reason);
});The listener fires on lifecycle changes and on local edits. An edit publishes
the same state object, so compare article.replicaRevision to tell the two
apart. getSnapshot() reads the current state without subscribing.
status | Read | Write |
|---|---|---|
idle | no | no |
authorizing, connecting, synchronizing | no | no |
ready | yes | yes |
offline | yes | yes |
suspended | yes | no |
recovery-required, denied, failed | no | no |
closed | no | no |
ready carries fieldGrants, fieldSyncing and pendingUpdates. offline
carries fieldGrants and pendingUpdates. denied and failed carry a
code, and failed says whether it is retryable.
Disconnect and close
connect() counts references. Two calls need two await article.disconnect()
calls before the transport comes down. Call your own unsubscribe() alongside
the last one.
The last disconnect() leaves the handle at offline when it already read
something. Otherwise it lands on idle. close() is terminal, and connect()
after it rejects with CLOSED.
Awareness
article.awareness.set(
{ name: "Ada" },
{ activeField: "content", cursor: 12, selectionEnd: 20 },
);
const stopRoster = article.awareness.subscribe((participants) => {
renderCollaborators(participants);
});awareness.enabled is false unless the owner declared
.collaborative({ awareness }). Calling set on an owner without it throws
INVALID_OPERATION. Calling it before the document is ready throws NOT_READY.
The server parses your value with the Zod schema you declared. The browser caps
it at 512 bytes of canonical JSON first. It also refuses the keys activeField,
cursor and selectionEnd, because the protocol uses those itself.
activeField must name a text field you can read. The roster hands back each
participant, their sessions, and each session's value, expiresAtMs and
active. Cursor positions come back as offsets into your own copy of the text.
clear() removes your entry, and getRoster() reads the last roster without
subscribing.
All of this is plain TypeScript. No React, no Tiptap, no ProseMirror, no TanStack Query. Keep the handle where its lifetime belongs. Subscribe with your own state adapter and disconnect during cleanup.
The working result
Two people have the same article open. One types in the title while the other types in the body. Both see the other's characters arrive. Neither has to save. Neither overwrites the other. Every ordinary query you already wrote still reads the merged columns.
Where each topic lives
| Topic | Page |
|---|---|
| Positions that survive other people's edits | Text anchors |
| Editing with no network, and coming back | Offline and recovery |
| Marking the fields in the first place | Collaborative documents |
| Routes, engines and protocol limits | Runtime and limits |
Next
Collaborative article builds one of these end to end, from the collection file to the connected editor.