Skip to main content

React

Lexical provides LexicalCollaborationPlugin and useCollaborationContext hook within @lexical/react to accelerate creation of the collaborative React backed editors. This is on top of the Yjs bindings provided by @lexical/yjs.

tip

Clone Lexical GitHub repo, run npm i && npm run start and open http://localhost:3000/split/?isCollab=true to launch playground in collaborative mode.

Getting started

This guide is based on examples/react-rich example.

Install minimal set of the required dependencies:

$ npm i -S @lexical/react @lexical/yjs lexical react react-dom y-websocket @y/websocket-server yjs
note

y-websocket is the only officially supported Yjs connection provider at this point. Although other providers may work just fine.

Get WebSocket server running:

This allows different browser windows and different browsers to find each other and sync Lexical state. On top of this YPERSISTENCE allows you to save Yjs documents in between server restarts so clients can simply reconnect and keep editing.

$ HOST=localhost PORT=1234 YPERSISTENCE=./yjs-wss-db npx y-websocket

Get basic collaborative Lexical setup:

import { useCallback } from 'react';

import {$getRoot, $createParagraphNode, $createTextNode} from 'lexical';
import {LexicalCollaboration} from '@lexical/react/LexicalCollaborationContext';
import {LexicalComposer} from '@lexical/react/LexicalComposer';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import {CollaborationPlugin} from '@lexical/react/LexicalCollaborationPlugin';
import * as Y from 'yjs';
import {$initialEditorState} from './initialEditorState';
import {WebsocketProvider} from 'y-websocket';

function Editor() {
const initialConfig = {
// NOTE: This is critical for collaboration plugin to set editor state to null. It
// would indicate that the editor should not try to set any default state
// (not even empty one), and let collaboration plugin do it instead
editorState: null,
namespace: 'Demo',
nodes: [],
onError: (error: Error) => {
throw error;
},
theme: {},
};

const getDocFromMap = (id: string, yjsDocMap: Map<string, Y.Doc>): Y.Doc => {
let doc = yjsDocMap.get(id);

if (doc === undefined) {
doc = new Y.Doc();
yjsDocMap.set(id, doc);
} else {
doc.load();
}

return doc;
}

const providerFactory = useCallback(
(id: string, yjsDocMap: Map<string, Y.Doc>) => {
const doc = getDocFromMap(id, yjsDocMap);

return new WebsocketProvider('ws://localhost:1234', id, doc, {
connect: false,
});
}, [],
);

return (
<LexicalCollaboration>
<LexicalComposer initialConfig={initialConfig}>
<RichTextPlugin
contentEditable={<ContentEditable className="editor-input" />}
placeholder={<div className="editor-placeholder">Enter some rich text...</div>}
ErrorBoundary={LexicalErrorBoundary}
/>
<CollaborationPlugin
id="lexical/react-rich-collab"
providerFactory={providerFactory}
/>
</LexicalComposer>
</LexicalCollaboration>
);
}

Initial editor content:

In a production environment, you should bootstrap the editor's initial content on the server. If bootstrapping was left to the client and two clients connected at the same time, they could both try to initialize the content resulting in document corruption.

Using the withHeadlessCollaborationEditor function from the FAQ page, you can create a bootstrapped Y.Doc with the following:

import type {CreateEditorArgs} from 'lexical';

import {$getRoot, $createParagraphNode} from 'lexical';
import {Doc} from 'yjs';

import {withHeadlessCollaborationEditor} from './withHeadlessCollaborationEditor';

function createBootstrappedYDoc(nodes: CreateEditorArgs['nodes']): Doc {
return withHeadlessCollaborationEditor(nodes, (editor) => {
const yDoc = new Doc();
editor.update(() => {
$getRoot().append($createParagraphNode());
}, {discrete: true});
return yDoc;
});
}

If you're simply following the above example to play around in a local dev environment, then you can add the following props to CollaborationPlugin to initialize the editor state client-side:

// Dev-testing only, do not use in real-world cases.
initialEditorState={$initialEditorState}
shouldBootstrap={true}

See it in action

Source code: examples/react-rich-collab

Building collaborative plugins

Lexical Playground features set of the collaboration enabled plugins that integrate with primary document via useCollaborationContext() hook. Notable mentions:

  • CommentPlugin - features use of the separate provider and Yjs room to sync comments.
  • ImageComponent - features use of the LexicalNestedComposer paired with CollaborationPlugin.
  • PollOptionComponent - showcases poll implementation using clientID from Yjs context.
  • StickyPlugin - features use of the LexicalNestedComposer paired with CollaborationPlugin as well as sticky note position real-time sync.
note

While these "playground" plugins aren't production ready - they serve as a great example of collaborative Lexical capabilities as well as provide a good starting point.

Custom node property syncing

@lexical/yjs syncs custom node properties by constructing a fresh node instance and inspecting its own enumerable properties. This means every property that should be synced across peers must be assigned in the constructor, even if the initial value is undefined.

warning

Declaring a class property with TypeScript's optional syntax (foo?: Type) does not guarantee the property exists as an own enumerable property on the instance. Under TypeScript's default compilation settings (target < ES2022 without useDefineForClassFields), such declarations produce no initialization code.

What to do

Always initialize every node property in the constructor. Conditional initialization such as if (someValue !== undefined) this.__someValue = someValue is not enough, because the default construction path still needs to create the property.

For example:

// ✅ Correct — property is guaranteed to be an own property
class MyNode extends ElementNode {
__someValue: string | undefined;

constructor(someValue?: string, key?: NodeKey) {
super(key);
this.__someValue = someValue; // explicitly initialized
}
}

// ❌ Incorrect — property may not exist as an own property
class MyNode extends ElementNode {
__someValue?: string; // optional syntax without initialization

constructor(key?: NodeKey) {
super(key);
// __someValue is never assigned → won't be synced via yjs
}
}

If you use NodeState for your custom properties, this concern does not apply. NodeState values are synced correctly without relying on constructor-created enumerable properties.

Yjs providers

Setting up the communication between clients, managing awareness information, and storing shared data for offline usage is quite a hassle. Providers manage all that for you and are the perfect starting point for your collaborative app.

See Yjs Website for the list of the officially endorsed providers. Although it's not an exhaustive one.