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- rawY.Docaccess 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:
- Returns
401if no verified JWT is present on the upgrade. - Looks up
documents[docId](the docs feature's collection) and resolves a Yjs role from the row:adminwhen the caller isdocuments.ownerIdor the app'sOWNER_USER_ID.memberwhen the caller's id is indocuments.editors(JSON array stored as text).viewerwhen the caller's id is indocuments.collaborators(JSON array stored as text).403otherwise.
- When the app has no
documentscollection registered (or the row is not found), the handler falls through to'member'for any authenticated user, so apps that useuseYjsRoomwithout 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-prosemirrorbinding. - Monaco -
y-monacofor 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#
- Real-time reference - full hook signatures and return shapes.
- Canvas - collaborative shapes and viewports via
useCanvas. - Presence and cursors - live cursors and typing indicators.
- Real-time sync - how sync works under the hood.