Agora
Client

Sessions

One Y.Doc and one transport per document name — how a session starts, what each status means, how reconnection with fresh tokens works, and why the doc reference is stable across renders.

A session is everything the client needs for one document: a Y.Doc, a transport, a status, and a reconnect loop. The provider keeps one per document name and hands the same instance to every hook that asks for it.

const { doc, collabDoc, status, synced, error } = useCollabDoc({ docName: 'researches/42/writing' })

Shared, and reference counted

Three components on the same page asking for researches/42/writing get one session: one socket, one document, one set of listeners.

The session is reference counted. Each hook that mounts holds it open; when the last one unmounts the socket closes — but the Y.Doc stays in the provider's map, with everything typed into it:

first hook mounts   → token fetched, transport built
another hook mounts → same session, nothing new opens
last hook unmounts  → transport destroyed, reconnect timer cleared, Y.Doc kept
it mounts again     → fresh token, new transport, same Y.Doc — resumed, not restarted

So navigating away and back does close the socket, and coming back reconnects onto the document you left. Unmounting is not free (a token request and a handshake), but it is also not a data loss, and nothing is left holding a socket open behind a page nobody is looking at.

The doc reference is stable

doc is the same Y.Doc object on every render for as long as the provider lives — across unmount/remount and across every reconnect. That is what makes Collaboration.configure({ document: doc }) safe to pass into a useEditor config. You do not need to memoize it, and you should not key an editor on it.

Documents accumulate in the provider's map, one per document name visited. That is the point — it is what makes a remount resume — but it also means the provider is the lifetime to design around: mount it in the layout, not in a page. A provider that remounts on navigation throws its map away and every route change starts from an empty document.

session.destroy() is the terminal call, for a non-React frontend or a test: it destroys the Y.Doc too, and evicts the session from the provider's map so it is never handed back.

Do not call destroy() from a component

An earlier version of useCollabDoc destroyed the session in its effect cleanup. Two things went wrong. The Hocuspocus provider queues callbacks that touch the Y.Doc after teardown, and a destroyed document made them throw — which drives Tiptap's Collaboration extension into React error #185. And the destroyed session stayed in the map, so the remount got a corpse back that could never reconnect: connecting forever, with no error to show for it. Let the reference count do it.

collabDoc is the same document behind the engine-agnostic CollabDoc seam (read, write, onUpdate) — that is what the editor adapters and useExcalidrawSync consume. useExcalidrawSync also accepts the raw doc, and wraps it for you.

Starting up

Opening a document is a sequence, and status is how you watch it:

'connecting'  → token requested, transport being built
'connected'   → socket open — not the same as "the document is here"
'disconnected'→ socket closed; a reconnect is scheduled
'error'       → the token could not be fetched, or the handshake was refused

The session starts lazily, on the first mounted consumer, and starting is idempotent — ten components calling useCollabDoc for the same name produce one token request.

synced is the one to gate the first paint on. status === 'connected' only says the socket is open; synced says the server's initial state has actually been applied to the Y.Doc:

const { doc, status, synced, error } = useCollabDoc({ docName })

const editor = useEditor({
  extensions: [StarterKit.configure({ undoRedo: false }), Collaboration.configure({ document: doc })],
  editable: true,
})

if (error) return <DocumentUnavailable error={error} />
if (!synced) return <DocumentLoading />

Not for cosmetics: doc is handed back immediately, before the server's state arrives. Typing into it during connecting produces local operations that merge with the incoming state — which is the CRDT behaving correctly, and rarely what the user meant.

Do not flip `editable` off on a disconnect

Gating editable on the live connection — editable: status === 'connected' — reads well and behaves badly: a WebSocket that drops for four seconds silently turns the editor read-only mid-sentence, with no explanation. Yjs is built for offline edits; let people keep typing and show the connection state next to the title instead. Gate on synced for the initial load, when there is genuinely nothing to edit yet, and leave it alone after that.

On engines whose transport cannot separate the two (partykit, partyserver, a test double), synced falls back to reporting connected — never less informative than the status.

Skipping the first token request

The token request and the socket are serialized: nothing of the document can arrive until the first finishes. When the server rendered the page it already knew the answer, so it can just say it — issueCollaborationToken in the controller, initialTokens on the provider:

<CollaborationProvider initialTokens={{ [docName]: collabToken }}>
  <Editor docName={docName} />
</CollaborationProvider>

useCollabDoc({ docName, token }) accepts one directly as well. Either way the session builds its transport at mount with no HTTP request in front of it.

The token is used once. Everything below still happens exactly as it did: a reconnect fetches a fresh token, and a pre-issued token that is malformed or already expired is ignored in favour of the ordinary fetch — a stale page prop costs the first paint, never the connection. See Token flow.

Reconnecting, and why the token is refetched

Tokens expire. So on a disconnect the session does not retry the same socket — it tears the transport down and rebuilds it with a fresh token:

disconnected → wait 1s  → new token → new transport
             → wait 2s  → …
             → wait 4s  → …
             → capped at 15s
             → a successful connection resets the backoff to 1s

The side effect is the useful part: every reconnect re-runs your authorize. Revoke someone's access while they are connected and it takes effect on their next reconnect, with no reload and no separate revocation channel.

error holds the last failure and stays set while the loop retries, so you can show "reconnecting, last error: …" without wiring your own state.

Awareness

useAwareness reads the same session's ephemeral peer state:

const { peers, status, awareness, setLocalState } = useAwareness({
  docName,
  user: { userId: me.id, name: me.name, avatarUrl: me.avatarUrl },
})
// peers:     [{ clientId, userId, name, avatarUrl }] — everyone except you
// awareness: the channel object itself, or null before the transport connects

Publish before you read. peers is built from the user field of everyone else's awareness state, so if nobody publishes one, everybody sees a room full of anonymous client ids. The user option does it for you; setLocalState is the general form, for a cursor position or a selection alongside the identity:

setLocalState({ user: { userId: me.id, name: me.name }, cursor: { x, y } })

Whatever you last published is re-applied to the channel after every reconnect — the transport is rebuilt with a new Awareness each time, and without that you would quietly vanish from everyone else's presence list on the first dropped socket.

Two views of one channel, for two different jobs:

  • peers is a snapshot — re-read on every awareness change, and what you render an avatar stack or a presence list from.
  • awareness is the channel — what an editor extension needs in order to publish the local cursor and subscribe to remote ones. Tiptap's CollaborationCaret takes a provider and reads provider.awareness, so { awareness } is a complete provider as far as it is concerned. See Tiptap collaboration.

The hook re-renders on every awareness change: someone joining, leaving, or moving a cursor. That is frequent by design — this is the cursor channel — so render peers into something cheap, and do not run effects off it that hit the network.

awareness is null until the transport connects, and it is rebuilt on every reconnect. Key anything that holds onto it on the object itself, or a reconnected session leaves your extension publishing into an abandoned channel.

Awareness is per-process, not global

peers is everyone connected to the same document room on the same server process. For the cross-instance answer — "is anyone in this document, anywhere" — ask the server's presence roster.

Below the hook

DocSession is exported, for a non-React frontend or a test:

import { DocSession } from '@adonis-agora/collaboration-client'

const session = new DocSession('researches/42/writing', { baseUrl: 'https://api.example.com' })
const unsubscribe = session.subscribe(() => console.log(session.getStatus()))

await session.start()          // idempotent
session.doc                    // the Y.Doc
session.engine                 // what the server said, once the token arrived
session.synced                 // initial state applied, not just "socket open"

const release = session.retain()  // hold it open (what the hooks do on mount)
release()                         // last release closes the transport, keeps the doc
session.stop()                    // same teardown, forced
session.destroy()                 // terminal: also destroys the Y.Doc, evicts from the map

subscribe starts the session too, so a subscriber never misses the first transition. retain is what makes the lifetime work: it returns its own release, so an effect can return session.retain() and never double-release.

Testing without a server

createTransport on the provider replaces the transport factory wholesale — the token is still fetched (stub fetchImpl to avoid that), but nothing opens a socket:

<CollaborationProvider
  baseUrl="http://test"
  fetchImpl={fakeFetchReturningAToken}
  createTransport={(info, doc) => new FakeTransport(doc)}
>

See Testing.

On this page