Skip to main content

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

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

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 filterableThe data has interactive merging needs
RBAC needs to filter visibilityAll editors should see the same content
Updates are coarse-grainedUpdates 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