Realtime Collaboration

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:

PropWhat it does
externalContentSkip the built-in document loader — Yjs will populate the doc instead.
externalPluginsPass Yjs's ProseMirror plugins (ySyncPlugin, yCursorPlugin, yUndoPlugin).
comments + onCommentsChangeControlled 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-webrtc

y-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:

ProviderBest forSwap
y-partykit (Cloudflare)Edge-hosted rooms with storagenew YPartyKitProvider(host, room, ydoc)
@liveblocks/yjsManaged presence + authnew LiveblocksYjsProvider(room, ydoc)
@hocuspocus/providerSelf-hosted Node.js servernew HocuspocusProvider({ url, name, document })
y-websocketRoll-your-own WebSocket servernew 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:

examples/collaboration on GitHub

The same code powers the Collaborate button in the online editor on this site.