Realtime collaboration

Sync DocxEditor across users with Yjs, live cursors, presence, comment sync, and tracked changes attribution.

Bind DocxEditor to a Yjs document to get live multi-user editing: cursors, presence, comment sync, and tracked-change attribution.

Try it in 30 seconds: open the collaboration demo, share the URL with a tab or a colleague. (Or visit the online editor and click Collaborate.)

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 through ySyncPlugin because they are stored as ProseMirror marks.

Step 1. Install

npm install yjs y-prosemirror y-webrtc

y-webrtc uses public signaling servers, so it is useful for local tests and prototypes. For application deployments, use a provider you operate or a hosted provider such as PartyKit, Liveblocks, or Hocuspocus.

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 }) {
  // Construct the provider in an effect (not useMemo): y-webrtc registers the
  // room as a side effect, and an aborted render would leak it.
  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-editor-react';
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.

Two small changes to Step 2's hook first: keep the Y.Doc in the state object (setState({ provider, plugins, ydoc })) and return it alongside the plugins (return { plugins: state?.plugins ?? null, ydoc: state?.ydoc ?? null }). Step 3's call becomes const { plugins, ydoc } = useCollaboration(room, user);. Then this self-contained hook does the mirroring:

// useSharedComments.ts
import { useCallback, useEffect, useState } from 'react';
import type * as Y from 'yjs';
import type { Comment } from '@eigenpal/docx-editor-core';

export function useSharedComments(ydoc: Y.Doc | null) {
  const [comments, setCommentsState] = useState<Comment[]>([]);

  // Mirror Y.Array → React state.
  useEffect(() => {
    if (!ydoc) return;
    const yComments = ydoc.getArray<Comment>('comments');
    const sync = () => setCommentsState(yComments.toArray());
    sync();
    yComments.observeDeep(sync);
    return () => yComments.unobserveDeep(sync);
  }, [ydoc]);

  // Push React state → Y.Array.
  const setComments = useCallback(
    (next: Comment[]) => {
      if (!ydoc) return;
      const yComments = ydoc.getArray<Comment>('comments');
      ydoc.transact(() => {
        yComments.delete(0, yComments.length);
        yComments.push(next);
      });
    },
    [ydoc]
  );

  return { comments, setComments };
}

Then pass to the editor:

const { comments, setComments } = useSharedComments(ydoc);

<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; that avoids two users clobbering each other's edits.

Production providers

y-webrtc is fine for demos: peers connect directly and there's no server. Real apps 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 implement the same Yjs provider role as WebrtcProvider, but their auth, persistence, and deployment setup differ. For example, switching to PartyKit means installing y-partykit partysocket, deploying a small party/server.ts that delegates to y-partykit's onConnect, and changing the provider constructor 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 repo:

examples/collaboration on GitHub

The same code powers the Collaborate button in the online editor on this site. Direct deep-link: /editor?collaborate=1.

Next steps

On this page