Realtime Collaboration
Sync DocxEditor across users with Yjs — live cursors, presence, comment sync, and tracked changes attribution.
Add live multi-user editing — cursors, presence, comment sync, tracked-change attribution — by binding DocxEditor to a Yjs document.
Try it in 30 seconds: open the online editor, click Collaborate, share the URL with a tab/colleague.
How it works
The editor exposes three props that hand off state to a CRDT:
| Prop | What it does |
|---|---|
externalContent | Skip the built-in document loader — Yjs will populate the doc instead. |
externalPlugins | Pass Yjs's ProseMirror plugins (ySyncPlugin, yCursorPlugin, yUndoPlugin). |
comments + onCommentsChange | Controlled comment threads, mirrored to a Y.Array on the same Y.Doc. |
Tracked changes sync for free — they're ProseMirror marks, so ySyncPlugin carries them along with everything else.
Step 1 — Install
npm install yjs y-prosemirror y-webrtcy-webrtc uses public free signaling and needs zero infra — perfect for getting started. We'll cover production providers (PartyKit, Liveblocks, Hocuspocus) below.
Step 2 — Set up a shared Y.Doc
// useCollaboration.ts
import { useEffect, useState } from 'react';
import * as Y from 'yjs';
import { WebrtcProvider } from 'y-webrtc';
import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from 'y-prosemirror';
import type { Plugin } from 'prosemirror-state';
export function useCollaboration(roomName: string, user: { name: string; color: string }) {
// Hold the Y.Doc + provider in state, constructed inside an effect (not
// useMemo). `new WebrtcProvider` registers the room in y-webrtc's module-
// level map as a render-time side effect — if a render is aborted (e.g. a
// CSP-blocked signaling socket throws, and an error boundary retries the
// subtree), the first provider is never destroyed and the retry's second
// `new WebrtcProvider(roomName, …)` throws "A Yjs Doc already exists for
// room …". Pairing creation with cleanup inside an effect closes the gap.
const [state, setState] = useState<{
provider: WebrtcProvider;
plugins: Plugin[];
} | null>(null);
useEffect(() => {
const ydoc = new Y.Doc();
const provider = new WebrtcProvider(roomName, ydoc);
const fragment = ydoc.getXmlFragment('prosemirror');
const plugins = [
ySyncPlugin(fragment), // syncs the PM doc
yCursorPlugin(provider.awareness), // remote cursors
yUndoPlugin(), // shared undo/redo
];
setState({ provider, plugins });
return () => {
provider.destroy();
ydoc.destroy();
setState(null);
};
}, [roomName]);
// Publish identity so peers can render avatars and labelled cursors.
useEffect(() => {
state?.provider.awareness.setLocalStateField('user', user);
}, [state, user.name, user.color]);
return state?.plugins ?? null;
}The hook returns null until the provider is ready — guard the editor render on it (shown in Step 3).
Step 3 — Pass it to DocxEditor
import { DocxEditor, createEmptyDocument } from '@eigenpal/docx-js-editor';
import { useCollaboration } from './useCollaboration';
export function CollaborativeEditor({ room, user }) {
const plugins = useCollaboration(room, user);
// Hold the editor mount until the Y.Doc + provider are ready. ySyncPlugin
// has to attach on initial EditorState construction — swapping plugins in
// later doesn't repopulate the doc.
if (!plugins) return <div>Joining room…</div>;
return (
<DocxEditor
document={createEmptyDocument()} // schema seed — Yjs owns the content
externalContent
externalPlugins={plugins}
author={user.name}
showToolbar
showRuler
/>
);
}Open the page in two tabs with the same room — type in one, watch the other update with a labelled cursor showing the remote user's color.
Adding comment sync
Comment threads (text, replies, resolved status) live outside the PM doc. Mirror them through the controlled comments API to a Y.Array on the same Y.Doc:
import { useCallback, useEffect, useState } from 'react';
import type { Comment } from '@eigenpal/docx-js-editor';
// Extend the setup effect from Step 2 — create the Y.Array alongside plugins
// and hand it out through the same state object:
// const yComments = ydoc.getArray<Comment>('comments');
// setState({ provider, plugins, ydoc, yComments });
// Mirror Y.Array → React state (runs when `state` becomes non-null).
const [comments, setCommentsState] = useState<Comment[]>([]);
useEffect(() => {
if (!state) return;
const { yComments } = state;
const sync = () => setCommentsState(yComments.toArray());
sync();
yComments.observeDeep(sync);
return () => yComments.unobserveDeep(sync);
}, [state]);
// Push React state → Y.Array
const setComments = useCallback((next: Comment[]) => {
if (!state) return;
const { ydoc, yComments } = state;
ydoc.transact(() => {
yComments.delete(0, yComments.length);
yComments.push(next);
});
}, [state]);Then pass to the editor:
<DocxEditor
/* …other props… */
comments={comments}
onCommentsChange={setComments}
/>Production tip: the snippet above replaces the entire array on every change. For high-concurrency rooms, store comments in a
Y.Map<commentId, Comment>and apply diffs by id — avoids two users clobbering each other's edits.
Production providers
y-webrtc is great for demos but needs no server — peers connect directly. For real apps you want a backed provider with persistence and access control:
| Provider | Best for | Swap |
|---|---|---|
y-partykit (Cloudflare) | Edge-hosted rooms with storage | new YPartyKitProvider(host, room, ydoc) |
@liveblocks/yjs | Managed presence + auth | new LiveblocksYjsProvider(room, ydoc) |
@hocuspocus/provider | Self-hosted Node.js server | new HocuspocusProvider({ url, name, document }) |
y-websocket | Roll-your-own WebSocket server | new WebsocketProvider(url, room, ydoc) |
All four are drop-in replacements for WebrtcProvider — every other line of the hook stays the same. For example, switching to PartyKit means installing y-partykit partysocket, deploying a tiny party/server.ts that delegates to y-partykit's onConnect, and changing one line in the hook:
import YPartyKitProvider from 'y-partykit/provider';
const provider = new YPartyKitProvider('docx-collab.your-account.partykit.dev', roomName, ydoc);Full example
A complete runnable example — full hook, AvatarStack, identity helper, App.tsx wiring — lives in the docx-editor monorepo:
The same code powers the Collaborate button in the online editor on this site.