> ## Documentation Index
> Fetch the complete documentation index at: https://docs.deep.space/llms.txt
> Use this file to discover all available pages before exploring further.

# Collaborative editing

> Sync text and structured fields across clients with Yjs.

Collaborative editing in DeepSpace is backed by [Yjs](https://yjs.dev/), a CRDT library. The SDK ships three hooks for binding Yjs documents to React state.

* [`useYjsText`](/sdk-reference/client/realtime#useyjstext-collection-recordid-fieldname) - collaborative plain text bound to a record field.
* [`useYjsField`](/sdk-reference/client/realtime#useyjsfield-collection-recordid-fieldname) - raw `Y.Doc` access for structured types (maps, arrays, XML fragments) on a record field.
* [`useYjsRoom`](/sdk-reference/client/realtime#useyjsroom-docid-fieldname) - a standalone Yjs document not tied to any record.

`useYjsText` and `useYjsField` sync over the record's existing `RecordRoom` connection. `useYjsRoom` opens a dedicated `YjsRoom` Durable Object per `docId`. See the [real-time reference](/sdk-reference/client/realtime) for full signatures.

<Note>
  Record-bound hooks (`useYjsText`, `useYjsField`) require a `<RecordProvider>` ancestor and a registered collection schema with an existing record. The scaffold from `npm create deepspace` sets up the provider. `useYjsRoom` has neither requirement and connects directly to the `YjsRoom` DO.
</Note>

## Collaborative text

Bind a `<textarea>` to a Yjs `Y.Text` on a record. Two clients editing the same `(collection, recordId, fieldName)` see each other's keystrokes live.

```tsx theme={null}
import { useYjsText } from 'deepspace'

function DocEditor({ docId }: { docId: string }) {
  const { text, setText, synced, canWrite } = useYjsText('docs', docId, 'body')

  return (
    <textarea
      value={text}
      onChange={(e) => setText(e.target.value)}
      disabled={!synced || !canWrite}
    />
  )
}
```

Disable the input until `synced` is `true` so the user doesn't type into the document before initial state arrives. `canWrite` mirrors the collection's RBAC - see [Permissions](/concepts/permissions).

## Structured data

For lists, maps, or any non-text Yjs type, use [`useYjsField`](/sdk-reference/client/realtime#useyjsfield-collection-recordid-fieldname). It returns the raw `Y.Doc` plus an `updateCount` that ticks on every local or remote update - use it as a render trigger.

```tsx theme={null}
import { useEffect, useState } from 'react'
import { useYjsField } from 'deepspace'

type Card = { id: string; title: string }

function KanbanBoard({ boardId }: { boardId: string }) {
  const { doc, synced, canWrite, updateCount } = useYjsField('boards', boardId, 'cards')

  const cardsArray = doc.getArray<Card>('cards')
  const [cards, setCards] = useState<Card[]>(() => cardsArray.toArray())

  useEffect(() => {
    setCards(cardsArray.toArray())
  }, [cardsArray, updateCount])

  function moveCard(from: number, to: number) {
    if (!canWrite) return
    doc.transact(() => {
      const card = cardsArray.get(from)
      if (!card) return
      cardsArray.delete(from, 1)
      cardsArray.insert(to, [card])
    })
  }

  return (
    <div>
      {cards.map((card, i) => (
        <CardView key={card.id} card={card} onMove={(to) => moveCard(i, to)} />
      ))}
    </div>
  )
}
```

The hook returns the `Y.Doc`, not a value. Read whichever Yjs type you need off `doc` (`doc.getArray`, `doc.getMap`, `doc.getXmlFragment`) and rely on `updateCount` or a Yjs `observe` to drive React re-renders. For simple text, prefer `useYjsText`.

## Standalone rooms

Use [`useYjsRoom`](/sdk-reference/client/realtime#useyjsroom-docid-fieldname) for documents not tied to a record: scratchpads, whiteboards, ephemeral sessions. The first argument is any string `docId`; two clients passing the same `docId` connect to the same `YjsRoom` Durable Object.

```tsx theme={null}
import { useYjsRoom } from 'deepspace'

type Shape = { id: string; x: number; y: number }

function Whiteboard({ sessionId }: { sessionId: string }) {
  const { doc, synced, canWrite } = useYjsRoom(sessionId, 'notes')
  const shapes = doc.getArray<Shape>('shapes')

  function addShape(shape: Shape) {
    if (!canWrite) return
    shapes.push([shape])
  }

  if (!synced) return <p>Loading...</p>
  return <Canvas shapes={shapes.toArray()} onAdd={addShape} />
}
```

The second argument names a `Y.Text` field exposed as `text` / `setText` on the hook return - useful when the room is mostly a text doc. For non-text data, ignore those fields and reach into `doc` directly as shown above.

## YjsRoom authentication and roles

The `/ws/yjs/:docId` route in the scaffold is the only `/ws/*` route that is **token-required and docs-aware**. The other `/ws/*` routes use the inline `wsRoute` helper, which allows anonymous connections (see [Security model](/concepts/architecture#security-model-websocket-identity)). The Yjs handler instead:

1. Returns `401` if no verified JWT is present on the upgrade.
2. Looks up `documents[docId]` (the docs feature's collection) and resolves a Yjs role from the row:
   * `admin` when the caller is `documents.ownerId` or the app's `OWNER_USER_ID`.
   * `member` when the caller's id is in `documents.editors` (JSON array stored as text).
   * `viewer` when the caller's id is in `documents.collaborators` (JSON array stored as text).
   * `403` otherwise.
3. When the app has no `documents` collection registered (or the row is not found), the handler falls through to `'member'` for any authenticated user, so apps that use `useYjsRoom` without the docs feature still work.

The resolved `role` is appended to the DO URL as a query parameter; the DO uses it to set `canWrite` (admins and members write, viewers are read-only).

<Warning>
  **Don't replace the `/ws/yjs/:docId` handler with a bare `wsRoute` when adding the docs feature.** The role resolution above lives only in the dedicated handler. Swapping it for `wsRoute((env) => env.YJS_ROOMS)` makes every authenticated caller anonymous-equivalent in the DO's eyes - the visible bug is "everyone is a viewer" or "collaborators can't type," because the DO defaults to the most restrictive role when no `role` parameter arrives.
</Warning>

## Editor integrations

The Yjs ecosystem has bindings for popular editors. The SDK doesn't ship them, but the hooks expose the underlying `Y.Doc` and `awareness`, which is everything these bindings need.

* [Tiptap](https://tiptap.dev/docs/editor/getting-started/install/collaboration) - rich-text editor with Yjs collaboration.
* [ProseMirror](https://github.com/yjs/y-prosemirror) - the `y-prosemirror` binding.
* [Monaco](https://github.com/yjs/y-monaco) - `y-monaco` for code editors.

```tsx theme={null}
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import { useYjsField } from 'deepspace'

function RichTextEditor({ docId }: { docId: string }) {
  const { doc, awareness } = useYjsField('docs', docId, 'body')
  const editor = useEditor({
    extensions: [
      StarterKit.configure({ history: false }),
      Collaboration.configure({ document: doc, field: 'prosemirror' }),
      CollaborationCursor.configure({ provider: { awareness } }),
    ],
  })

  return <EditorContent editor={editor} />
}
```

Install `@tiptap/extension-collaboration-cursor` alongside Tiptap if you want peer cursors; pass `awareness` from the hook through its `provider` option.

## Cursors and selections

Yjs's awareness protocol broadcasts ephemeral peer state - cursors, selections, names. Both `useYjsField` and `useYjsRoom` expose an `awareness` instance. Pass it to an editor binding (like `@tiptap/extension-collaboration-cursor` shown above), or read and write it directly when building a custom UI.

The awareness instance returned from `useYjsRoom` is already wired to the same WebSocket: any call to `awareness.setLocalState` or `awareness.setLocalStateField` triggers an `MSG_AWARENESS` frame to peers, and incoming peer frames update the map you read with `awareness.getStates()`.

```tsx theme={null}
import { useEffect, useState } from 'react'
import { useYjsRoom } from 'deepspace'

type CursorState = { line: number; col: number }

function CursorAwareEditor({ docId }: { docId: string }) {
  const { text, setText, awareness, synced } = useYjsRoom(docId, 'body')
  const [peers, setPeers] = useState<Array<[number, CursorState]>>([])

  // Broadcast our cursor on every change.
  function handleCaretChange(line: number, col: number) {
    awareness.setLocalStateField('cursor', { line, col } satisfies CursorState)
  }

  // Read every peer's state. The map is keyed by Yjs clientID.
  useEffect(() => {
    const update = () => {
      const next: Array<[number, CursorState]> = []
      awareness.getStates().forEach((state, clientId) => {
        if (clientId === awareness.clientID) return
        const cursor = (state as { cursor?: CursorState }).cursor
        if (cursor) next.push([clientId, cursor])
      })
      setPeers(next)
    }
    awareness.on('change', update)
    update()
    return () => awareness.off('change', update)
  }, [awareness])

  if (!synced) return <p>Loading...</p>
  return <Editor text={text} onText={setText} onCaret={handleCaretChange} peers={peers} />
}
```

Conventional fields are `cursor`, `selection`, `user` (`{ name, color }`), and `typing`. Anything you put under a key with `setLocalStateField` shows up in every peer's `getStates()` map until you clear it or disconnect. The docs feature's Tiptap toolbar wires the same `awareness` instance into `@tiptap/extension-collaboration-cursor`, which handles the cursor/selection encoding for you - see [Editor integrations](#editor-integrations) above.

For the raw protocol primitives (`MSG_AWARENESS`, `encodeAwarenessMessage`, `handleAwarenessMessage`), see [low-level sync primitives](/sdk-reference/client/realtime#low-level-sync-primitives) in the reference. For non-Yjs presence (live cursors on a canvas, who's typing in a chat), use [`usePresenceRoom`](/guides/presence-and-cursors) instead - a separate, lighter-weight room type.

## When to use Yjs vs records

DeepSpace gives you two ways to share data. Pick based on what you're modeling.

| Use records when…                                         | Use Yjs when…                                 |
| --------------------------------------------------------- | --------------------------------------------- |
| Each row is a discrete entity (a todo, a message, a user) | The data is a single document being co-edited |
| Fields are queryable and filterable                       | The data has interactive merging needs        |
| RBAC needs to filter visibility                           | All editors should see the same content       |
| Updates are coarse-grained                                | Updates are character-by-character            |

Many apps use both - records for the list of documents, Yjs for the content of each document.

## Persistence

Record-bound Yjs fields (`useYjsText`, `useYjsField`) persist inside the record's `RecordRoom`. Standalone documents (`useYjsRoom`) persist inside a `YjsRoom` DO keyed by `docId`. In both cases the document is stored as a single SQLite blob that's re-encoded from the current `Y.Doc` state on every update, so the blob grows with the document's current size, not with edit history. New clients receive the full state on connection, and documents survive worker redeployments.

## Next steps

* [Real-time reference](/sdk-reference/client/realtime) - full hook signatures and return shapes.
* [Canvas](/guides/canvas) - collaborative shapes and viewports via `useCanvas`.
* [Presence and cursors](/guides/presence-and-cursors) - live cursors and typing indicators.
* [Real-time sync](/concepts/realtime-sync) - how sync works under the hood.
