Agora
Client

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

AdapterScene shapeEditor
createExcalidrawAdapter(){ elements, appState? }Excalidraw
createTldrawAdapter(){ snapshot } — records plus schematldraw
createTextAdapter()stringa 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:

app/components/collab/kanban_adapter.ts
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.

On this page