The preview runs two independent editors through a credential-free local room.
The room owns transport state and seeds its central Y.Doc before either editor
connects. Each editor binds its own Y.Doc and awareness instance to Plate.
Install the provider package that your app uses. For example:
Provider packages stay at the app boundary. The binding receives the provider's
exact Y.Doc, an optional awareness instance for presence, and an explicit
readiness source. It does not connect, disconnect, or destroy the provider.
This Hocuspocus adapter is local to the app because provider event contracts vary by package and version:
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { YjsInitialReadiness } from "platejs/yjs";
const createHocuspocusInitialReadiness = (
provider: HocuspocusProvider
): YjsInitialReadiness => ({
doc: provider.document,
getSnapshot: () => provider.synced,
subscribe(listener) {
const onSynced = () => listener();
provider.on("synced", onSynced);
let active = true;
return () =>
initialReady: true asserts that local or persistent loading for this document
generation is already complete. It does not grant permission to initialize an
empty shared room.
Every peer in a room must use the same compiled schema identity. Use a stable
schema id and version for persistent rooms. A nonempty room without schema metadata fails closed. A claimed, populated room keeps its schema identity for its lifetime, so run an explicit versioned document migration before any peer joins with a new schema, and bump the schema version whenever its semantics change.
import { createEditor } from "platejs/react";
import { YjsPlugin } from "platejs/yjs/react";
const Collaboration = YjsPlugin.create({
doc: provider.document,
initialReady: createHocuspocusInitialReadiness(provider),
awareness: provider.awareness,
rootName: roomId,
cursorData: { validate: isCollaborator },
});
const editor = createEditor({
schema: { id: "yjs-example", version: 1 },
plugins: [Collaboration],
initialValue,
});doc and initialReady are required. If awareness is present, its doc
must be the same object as doc. Presence methods and cursor hooks are available
only for bindings that include awareness.
rootName names the whole collaboration document: primary children live at rootName and named roots in ${rootName}:roots, one commit that changes several roots becomes one Yjs transaction and one history batch, and awareness rejects a selection whose anchor and focus sit in different roots.
For a normal server-backed room, persist a canonical Yjs update on the server and
omit seed. Use seed: true only when the app has already selected one
exclusive owner for a new empty room. A synchronized empty replica is not proof
of seed ownership; multiple seeders can create duplicate content.
The copied component exports a factory so each editor supplies its own binding resources and cursor-data validator:
import { CollaborationPlugin } from "@/components/editor/remote-cursor-overlay";
const Collaboration = CollaborationPlugin.create({
doc: provider.document,
initialReady: createHocuspocusInitialReadiness(provider),
awareness: provider.awareness,
rootName: roomId,
cursorData: { validate: isCollaborator },
});import { CollaborationPlugin } from "@/components/editor/remote-cursor-overlay";
const Collaboration = CollaborationPlugin.create
CollaborationPlugin maps selection styles and an afterEditable caret overlay
onto each Yjs descriptor that it creates. Edit the copied file to customize
colors, opacity, and labels.
For custom cursor UI, platejs/yjs/react exports the hooks the copied component uses: useYjsRemoteCursorIds(editor) reads stable membership, useYjsRemoteCursor(editor, clientId) reads one cursor, and useYjsRemoteCursorGeometry(editor, clientId, { editableRef }) reads its geometry in that exact view.
To paint remote selections yourself, read the current snapshot with editor.api.yjs.remoteCursors() and invalidate that paint with editor.api.yjs.subscribeRemoteCursors(listener), which fires only when resolved remote cursors change. Its cleanup is idempotent; retire it with the owning view.
Read provider connection state from the provider itself. Use the Yjs admission hook for the document binding state:
import { useYjsAdmissionStatus } from "platejs/yjs/react";
const status = useYjsAdmissionStatus(editor);
return (
<Editor
readOnly={status.state !== "ready"}
aria-busy={status.state === "waiting"}
/>
);import { useYjsAdmissionStatus } from "platejs/yjs/react";
const status
Render the error status in your application UI and offer a retry after its
underlying cause is fixed:
if (status.state === "error") {
return <button onClick={() => editor.api.yjs.retryImport()}>Retry</button>;
}if (status.state === "error") {
return <button onClick={() => editor.api.yjs.retryImport()}>Retry</button>;
}The UI gate prevents confusing input. The binding also rejects document commits before publication while admission is waiting or failed.
editor.api.yjs.setCursorData({ name: "Ada", color: "#7c3aed" });
const disconnect = () => {
editor.api.yjs.clearSelection();
provider.disconnect();
};
const reconnect = async () => {
await provider.connect();
if (editor.api.yjs.admissionStatus().state === "ready") {
editor.api.yjs.syncSelection();
}
};Selection publication reads the current mounted editor view. clearSelection()
withdraws that view's selection before a shared provider detaches. Provider
status, errors, and cleanup remain app-owned. Initial admission publishes the
current selection automatically; the explicit reconnect call applies to an
already admitted binding.
Every standalone shared effect declares collabReplay: "live" for an event delivered once to active peers, or "latest" only for an absolute, idempotent value that defines collabSnapshot(state). A "live" effect is never the only source of durable shared state. For long sessions, give one stable sharedEffectCompaction.authorityId per collaboration document; it checkpoints once every live recipient acknowledges, late joiners restore "latest" values and never replay expired "live" events, and editor.api.yjs.retireSharedEffectPeer(clientId) releases a peer the host knows is permanently gone. A different sharedEffectCompaction.authorityId for the same collaboration document is rejected; transfer compaction authority only through an explicit host-fenced document migration.
| Concern | Owner |
|---|---|
Y.Doc, readiness, and seed authority | App |
| Provider connection, authentication, persistence, and cleanup | App |
| Document admission and Yjs translation | platejs/yjs |
| Cursor metadata and remote cursor snapshots | platejs/yjs when awareness is supplied |
| Selection, editing, and undo/redo | platejs and platejs/history |
| Cursor presentation | Copied remote-cursor-overlay component |
Create HocuspocusProvider in app code, pass provider.document and
provider.awareness to the plugin, and keep its server URL, token, room name,
connection state, and destruction beside the provider.
Create the y-webrtc provider in app code and pass its exact document and
awareness objects to the binding. Production signaling and TURN infrastructure
remain app concerns.
Use y-indexeddb to restore the Y.Doc before declaring initial readiness.
IndexedDB does not provide remote awareness or cursor transport by itself.
awareness.doc === doc.seed: true to one app-selected owner only.retryImport() only after fixing their cause.import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { YjsInitialReadiness } from "platejs/yjs";
const createHocuspocusInitialReadiness = (
provider: HocuspocusProvider
): YjsInitialReadiness => ({
doc: provider.document,
getSnapshot: () => provider.synced,
subscribe(listener) {
const onSynced = () => listener();
provider.on("synced", onSynced);
let active = true;
return () => {
if (!active) return;
active = false;
provider.off("synced", onSynced);
};
},
});import { createEditor } from "platejs/react";
import { YjsPlugin } from "platejs/yjs/react";
const Collaboration = YjsPlugin.create({
doc: provider.document,
initialReady: createHocuspocusInitialReadiness(provider),
awareness: provider.awareness,
rootName: roomId,
cursorData: { validate: isCollaborator },
});
const editor = createEditor({
schema: { id: "yjs-example", version: 1 },
plugins: [Collaboration],
initialValue,
});editor.api.yjs.setCursorData({ name: "Ada", color: "#7c3aed" });
const disconnect = () => {
editor.api.yjs.clearSelection();
provider.disconnect();
};
const reconnect = async () => {
await provider.connect();
if (editor.api.yjs.admissionStatus().state === "ready") {
editor.api.yjs.syncSelection();
}
};