Skip to main content
Docs
Build with DeepSpace

Collaborative editing

Sync text and structured fields across clients with Yjs.

Collaborative editing in DeepSpace is backed by Yjs, a CRDT library. The SDK ships three hooks for binding Yjs documents to React state.

  • useYjsText - collaborative plain text bound to a record field.
  • useYjsField - raw Y.Doc access for structured types (maps, arrays, XML fragments) on a record field.
  • useYjsRoom - 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 for full signatures.

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.

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.

Structured data#

For lists, maps, or any non-text Yjs type, use useYjsField. It returns the raw Y.Doc plus an updateCount that ticks on every local or remote update - use it as a render trigger.

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 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.

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). 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).

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 - rich-text editor with Yjs collaboration.
  • ProseMirror - the y-prosemirror binding.
  • Monaco - y-monaco for code editors.
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().

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 above.

For the raw protocol primitives (MSG_AWARENESS, encodeAwarenessMessage, handleAwarenessMessage), see low-level sync primitives in the reference. For non-Yjs presence (live cursors on a canvas, who's typing in a chat), use usePresenceRoom 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#