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.
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.
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.
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.
| 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();
}
};