Editors
useCollabEditor and the adapter seam — how a scene maps into a document key, the three built-in adapters, writing your own, and when to drop down to the raw Y.Doc instead.
Some editors do not have a CRDT binding. Excalidraw and tldraw both hand you a whole scene — serialize it, restore it, done — and the natural way to share one is to mirror that scene into a key of the collaborative document.
useCollabEditor is that pattern, packaged:
import { createExcalidrawAdapter, useCollabEditor } from '@adonis-agora/collaboration-client'
const { state, update, status, synced, peers } = useCollabEditor({
docName: 'whiteboards/1',
editorId: 'excalidraw',
adapter: createExcalidrawAdapter(),
})Five things come back, and none of them mention a CRDT: the current scene, a way to write the next one, the connection status, whether the server's initial state has landed, and who else is here.
What editorId is
The key inside the document the scene lives under. That is what lets one document hold several surfaces — a canvas and a plain-text panel side by side, sharing a name, a version history and a comment store:
const canvas = useCollabEditor({ docName, editorId: 'canvas', adapter: createTldrawAdapter() })
const notes = useCollabEditor({ docName, editorId: 'notes', adapter: createTextAdapter() })Two components, two keys, one document, one socket.
The built-in adapters
| Adapter | Scene shape | Editor |
|---|---|---|
createExcalidrawAdapter() | { elements, appState? } | Excalidraw |
createTldrawAdapter() | { snapshot } — records plus schema | tldraw |
createTextAdapter() | string | a textarea, Monaco, anything plain |
The tldraw adapter stores what createTLStore({ snapshot }) needs to rehydrate, so any peer can
rebuild the board from the document alone. The text adapter falls back to reading a Y.Text at the
same key when no plain value is there, so it interoperates with a document another editor wrote.
function Whiteboard({ id }: { id: string }) {
const { state, update, synced } = useCollabEditor({
docName: `whiteboards/${id}`,
editorId: 'excalidraw',
adapter: createExcalidrawAdapter(),
})
// Mount the canvas only once the saved scene has arrived: `initialData` is
// read once, so painting an empty board first means painting it forever.
if (!synced) return <BoardLoading />
return (
<Excalidraw
initialData={{ elements: state.elements, appState: state.appState }}
onChange={(elements, appState) => update({ elements: [...elements], appState })}
/>
)
}synced, not status. A board rendered while status === 'connected' can still be empty — the
socket is open and the initial state has not arrived — and viewModeEnabled={status !== 'connected'}
locks the canvas every time the connection blips. Use canWrite from your own authorize result
for a genuinely read-only board, and synced for "not painted yet".
This is whole-scene replacement, not fine-grained merging
update(next) writes the entire scene into the key. Two people dragging different shapes at the
same instant produce two whole-scene writes, and the later one wins — the CRDT converges, but on the
scene as a unit rather than per shape.
For a canvas that is usually fine: edits are visible, people work in different regions, and the
peer cursors keep them out of each other's way. For a
surface where per-item concurrency genuinely matters, model the items as their own keys and write
an adapter that does — or bind the editor to Yjs types directly with
useCollabDoc.
Excalidraw without the adapter
Excalidraw drives its canvas through an imperative handle rather than props, so there is a second
door for it — useExcalidrawSync wires that handle to a document directly, and ExcalidrawBoard
is the whole thing (provider included) in one component:
const [api, setApi] = useState<ExcalidrawAPIHandle | null>(null)
const { doc } = useCollabDoc({ docName: 'whiteboards/1' })
useExcalidrawSync({ doc, excalidrawAPI: api })
return <Excalidraw excalidrawAPI={setApi} />doc takes either the raw Y.Doc or the collabDoc view — the wrapper is applied for you.
It stores the scene under the same single key as the adapter, so the last-write-wins caveat above
applies unchanged: the whole elements array is one value, and two people drawing at the same
instant resolve to one of the two boards, not a merge of both. An empty board is a real saved
state, not a missing one — erasing everything propagates and survives a reload.
Writing an adapter
The interface is two methods:
interface CollabEditorAdapter<S> {
empty(): S
create(doc: CollabDoc, editorId: string): { state: S; update(next: S): void }
}CollabDoc is the seam that keeps adapters engine-independent — read, write, onUpdate, and
nothing else:
import type { CollabEditorAdapter, CollabDoc } from '@adonis-agora/collaboration-client'
export interface KanbanBoard {
columns: { id: string; title: string; cardIds: string[] }[]
cards: Record<string, { id: string; title: string }>
}
export function createKanbanAdapter(): CollabEditorAdapter<KanbanBoard> {
const empty = (): KanbanBoard => ({ columns: [], cards: {} })
return {
empty,
create: (doc: CollabDoc, editorId: string) => ({
get state() {
return doc.read<KanbanBoard>(editorId) ?? empty()
},
update(next) {
doc.write(editorId, next)
},
}),
}
}Two rules. state must be a getter, not a captured value — the hook re-reads it on every
document update, and a snapshot taken at create time would never change. And empty() must return
a valid scene, because it is what a brand-new document looks like.
The hook caches the snapshot by document version — it re-reads state only after the document
actually changed — so returning a freshly built object on every read does not cause a render loop.
(It used to compare JSON.stringify(scene) instead, which serialized the whole board on every
stroke just to decide whether anything had moved.)
Adapter or raw document?
Use an adapter when the editor hands you a whole scene and takes one back — Excalidraw, tldraw, a form, a board. The component ends up with no CRDT in it at all.
Use useCollabDoc when the editor has its own CRDT
binding — Tiptap, ProseMirror, CodeMirror. Those bind to Yjs types directly and give you
character-level merging and live cursors, which no scene-replacement adapter can match. See
Tiptap collaboration.
Comments & versions
The two REST-backed hooks — their full shape, which operations update optimistically and which refetch, error handling, and why they keep working while the socket is down.
Advanced
The two escape hatches — replacing the built-in REST surface with your own controllers, and binding a rich-text editor directly to the shared document.