# Collaborative documents (/docs/client/collaborative-documents)

---
title: Collaborative documents
description: 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.
kind: guide
package: questpie
---

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

```ts title="src/lib/crdt.ts"
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

```ts
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"`.

<Callout type="warn" title="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.
</Callout>

## Read and edit a field

```ts
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

```ts
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

```ts
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

```ts
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](/docs/client/collaborative-documents/anchors)         |
| Editing with no network, and coming back    | [Offline and recovery](/docs/client/collaborative-documents/offline) |
| Marking the fields in the first place       | [Collaborative documents](/docs/schema/collaborative-documents)      |
| Routes, engines and protocol limits         | [Runtime and limits](/docs/schema/collaborative-documents/runtime)   |

## Next

**[Collaborative article](/docs/guides/collaborative-docs)** builds one of these
end to end, from the collection file to the connected editor.
