Building a Real-Time Collaborative Editor with React and Y.js
Introduction
Real-time collaboration has become a fundamental feature in modern web applications. From Google Docs to Notion, users expect to work together seamlessly on the same document simultaneously. Building such features, however, comes with significant challenges: handling concurrent edits, resolving conflicts, and maintaining data consistency across multiple clients.
Y.js (Yjs) is a powerful CRDT (Conflict-free Replicated Data Type) implementation that solves these problems elegantly. It provides automatic conflict resolution, efficient synchronization, and works seamlessly with popular editors like Monaco, CodeMirror, Quill, and Slate.
In this comprehensive guide, we'll build a production-ready collaborative editor from scratch using:
- Y.js: For CRDT-based state synchronization
- React: For building the user interface
- Quill: As our rich text editor
- Node.js + WebSocket: For real-time server synchronization
- TypeScript: For type safety
By the end of this tutorial, you'll have a fully functional collaborative editor with features like real-time cursor tracking, user awareness, and persistent document storage.
Understanding CRDTs and Y.js
What are CRDTs?
CRDTs (Conflict-free Replicated Data Types) are data structures that can be replicated across multiple computers in a network, where replicas can be updated independently and concurrently without coordination, and merge operations always resolve conflicts in a consistent way.
Key Properties:
- Commutativity: Operations can be applied in any order
- Associativity: Grouping operations doesn't affect the result
- Idempotency: Applying the same operation multiple times has the same effect as applying it once
Why Y.js?
Y.js stands out among CRDT implementations because of:
- Performance: Highly optimized with small memory footprint
- Editor Support: Built-in bindings for popular editors
- Network Agnostic: Works with WebSocket, WebRTC, or any transport
- Persistence: Easy integration with various storage backends
- Rich Data Types: Support for Text, Array, Map, and more
Y.js vs Operational Transformation (OT)
| Feature | Y.js (CRDT) | OT | |---------|-------------|-----| | Conflict Resolution | Automatic | Requires central server | | Offline Support | Excellent | Limited | | Complexity | Lower | Higher | | Scalability | High | Medium | | Implementation | Simpler | More complex |
Project Setup
Initial Setup
Let's create a new React project with TypeScript:
# Create React app with TypeScript
npx create-react-app collaborative-editor --template typescript
cd collaborative-editor
# Install Y.js and related packages
npm install yjs y-websocket y-quill quill react-quill
npm install --save-dev @types/quill
# Install additional dependencies
npm install express ws
npm install --save-dev @types/express @types/ws
Project Structure
collaborative-editor/
├── src/
│ ├── components/
│ │ ├── Editor.tsx
│ │ ├── Toolbar.tsx
│ │ ├── UserCursor.tsx
│ │ └── UserList.tsx
│ ├── hooks/
│ │ ├── useYDoc.ts
│ │ └── useAwareness.ts
│ ├── server/
│ │ └── websocket-server.ts
│ ├── types/
│ │ └── index.ts
│ ├── utils/
│ │ └── colors.ts
│ ├── App.tsx
│ └── index.tsx
├── package.json
└── tsconfig.json
Building the WebSocket Server
First, let's create a WebSocket server to handle synchronization between clients.
Server Implementation
// src/server/websocket-server.ts
import WebSocket from 'ws';
import http from 'http';
import { setupWSConnection } from 'y-websocket/bin/utils';
const PORT = process.env.PORT || 4000;
// Create HTTP server
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Y.js WebSocket Server Running\n');
});
// Create WebSocket server
const wss = new WebSocket.Server({ server });
// Store active documents
const documents = new Map();
wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => {
console.log('New client connected');
// Setup Y.js connection
setupWSConnection(ws, req, {
gc: true, // Enable garbage collection
});
ws.on('close', () => {
console.log('Client disconnected');
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
});
});
server.listen(PORT, () => {
console.log(`WebSocket server running on port ${PORT}`);
});
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('Shutting down server...');
wss.close(() => {
server.close(() => {
process.exit(0);
});
});
});
Enhanced Server with Persistence
For production use, you'll want to persist documents:
// src/server/websocket-server-persistent.ts
import WebSocket from 'ws';
import http from 'http';
import * as Y from 'yjs';
import * as syncProtocol from 'y-protocols/sync';
import * as awarenessProtocol from 'y-protocols/awareness';
import * as encoding from 'lib0/encoding';
import * as decoding from 'lib0/decoding';
import { LeveldbPersistence } from 'y-leveldb';
const PORT = process.env.PORT || 4000;
const PERSISTENCE_DIR = './db';
// Initialize persistence
const persistence = new LeveldbPersistence(PERSISTENCE_DIR);
interface Room {
name: string;
doc: Y.Doc;
awareness: awarenessProtocol.Awareness;
connections: Set<WebSocket>;
}
const rooms = new Map<string, Room>();
function getRoom(roomName: string): Room {
if (!rooms.has(roomName)) {
const doc = new Y.Doc();
const awareness = new awarenessProtocol.Awareness(doc);
// Load persisted document
persistence.getYDoc(roomName).then((persistedDoc) => {
Y.applyUpdate(doc, Y.encodeStateAsUpdate(persistedDoc));
});
// Setup persistence updates
doc.on('update', (update: Uint8Array) => {
persistence.storeUpdate(roomName, update);
});
const room: Room = {
name: roomName,
doc,
awareness,
connections: new Set(),
};
rooms.set(roomName, room);
}
return rooms.get(roomName)!;
}
function setupConnection(ws: WebSocket, roomName: string) {
const room = getRoom(roomName);
room.connections.add(ws);
// Send sync step 1
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, syncProtocol.messageYjsSyncStep1);
syncProtocol.writeSyncStep1(encoder, room.doc);
ws.send(encoding.toUint8Array(encoder));
// Send awareness states
const awarenessStates = room.awareness.getStates();
if (awarenessStates.size > 0) {
const awarenessEncoder = encoding.createEncoder();
encoding.writeVarUint(awarenessEncoder, awarenessProtocol.messageAwareness);
encoding.writeVarUint8Array(
awarenessEncoder,
awarenessProtocol.encodeAwarenessUpdate(
room.awareness,
Array.from(awarenessStates.keys())
)
);
ws.send(encoding.toUint8Array(awarenessEncoder));
}
// Update handler
const updateHandler = (update: Uint8Array, origin: any) => {
if (origin !== ws) {
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, syncProtocol.messageYjsUpdate);
encoding.writeVarUint8Array(encoder, update);
const message = encoding.toUint8Array(encoder);
room.connections.forEach((conn) => {
if (conn !== ws && conn.readyState === WebSocket.OPEN) {
conn.send(message);
}
});
}
};
room.doc.on('update', updateHandler);
// Awareness handler
const awarenessChangeHandler = (
{ added, updated, removed }: any,
origin: any
) => {
const changedClients = added.concat(updated).concat(removed);
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, awarenessProtocol.messageAwareness);
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(room.awareness, changedClients)
);
const message = encoding.toUint8Array(encoder);
room.connections.forEach((conn) => {
if (conn.readyState === WebSocket.OPEN) {
conn.send(message);
}
});
};
room.awareness.on('change', awarenessChangeHandler);
// Message handler
ws.on('message', (message: Buffer) => {
const decoder = decoding.createDecoder(message);
const messageType = decoding.readVarUint(decoder);
switch (messageType) {
case syncProtocol.messageYjsSyncStep1: {
const encoderSync = encoding.createEncoder();
encoding.writeVarUint(encoderSync, syncProtocol.messageYjsSyncStep2);
syncProtocol.writeSyncStep2(encoderSync, room.doc, decoder);
ws.send(encoding.toUint8Array(encoderSync));
break;
}
case syncProtocol.messageYjsSyncStep2: {
syncProtocol.readSyncStep2(decoder, room.doc, ws);
break;
}
case syncProtocol.messageYjsUpdate: {
const update = decoding.readVarUint8Array(decoder);
Y.applyUpdate(room.doc, update, ws);
break;
}
case awarenessProtocol.messageAwareness: {
awarenessProtocol.applyAwarenessUpdate(
room.awareness,
decoding.readVarUint8Array(decoder),
ws
);
break;
}
}
});
// Cleanup on close
ws.on('close', () => {
room.connections.delete(ws);
room.doc.off('update', updateHandler);
room.awareness.off('change', awarenessChangeHandler);
// Clean up room if no connections
if (room.connections.size === 0) {
room.doc.destroy();
rooms.delete(roomName);
}
});
}
const server = http.createServer();
const wss = new WebSocket.Server({ server });
wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => {
const url = new URL(req.url!, `http://${req.headers.host}`);
const roomName = url.pathname.slice(1) || 'default';
console.log(`Client connected to room: ${roomName}`);
setupConnection(ws, roomName);
});
server.listen(PORT, () => {
console.log(`WebSocket server with persistence running on port ${PORT}`);
});
Creating the React Editor Component
Custom Hook for Y.js Document
// src/hooks/useYDoc.ts
import { useEffect, useState } from 'react';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
interface UseYDocOptions {
roomName: string;
serverUrl?: string;
}
interface UseYDocReturn {
doc: Y.Doc;
provider: WebsocketProvider;
synced: boolean;
}
export const useYDoc = ({
roomName,
serverUrl = 'ws://localhost:4000',
}: UseYDocOptions): UseYDocReturn | null => {
const [state, setState] = useState<UseYDocReturn | null>(null);
useEffect(() => {
// Create Y.js document
const doc = new Y.Doc();
// Create WebSocket provider
const provider = new WebsocketProvider(serverUrl, roomName, doc, {
connect: true,
});
// Track sync state
let synced = false;
const handleSync = (isSynced: boolean) => {
synced = isSynced;
setState({ doc, provider, synced: isSynced });
};
provider.on('sync', handleSync);
// Cleanup
return () => {
provider.off('sync', handleSync);
provider.destroy();
doc.destroy();
};
}, [roomName, serverUrl]);
return state;
};
Awareness Hook for User Presence
// src/hooks/useAwareness.ts
import { useEffect, useState } from 'react';
import { WebsocketProvider } from 'y-websocket';
import { Awareness } from 'y-protocols/awareness';
export interface UserState {
name: string;
color: string;
cursor?: {
anchor: number;
head: number;
};
}
export const useAwareness = (
provider: WebsocketProvider | null,
localUser: UserState
) => {
const [users, setUsers] = useState<Map<number, UserState>>(new Map());
useEffect(() => {
if (!provider) return;
const awareness = provider.awareness;
// Set local user state
awareness.setLocalState(localUser);
// Update users when awareness changes
const updateUsers = () => {
const states = new Map<number, UserState>();
awareness.getStates().forEach((state, clientId) => {
if (clientId !== awareness.clientID) {
states.set(clientId, state as UserState);
}
});
setUsers(states);
};
awareness.on('change', updateUsers);
updateUsers();
// Cleanup
return () => {
awareness.off('change', updateUsers);
};
}, [provider, localUser]);
return users;
};
Utility for User Colors
// src/utils/colors.ts
const colors = [
'#FF6B6B', // Red
'#4ECDC4', // Teal
'#45B7D1', // Blue
'#FFA07A', // Light Salmon
'#98D8C8', // Mint
'#F7DC6F', // Yellow
'#BB8FCE', // Purple
'#85C1E2', // Sky Blue
'#F8B739', // Orange
'#52BE80', // Green
];
export const getRandomColor = (): string => {
return colors[Math.floor(Math.random() * colors.length)];
};
export const getUserColor = (userId: string): string => {
// Generate consistent color based on user ID
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = userId.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
};
Main Editor Component
// src/components/Editor.tsx
import React, { useEffect, useRef, useState } from 'react';
import Quill from 'quill';
import { QuillBinding } from 'y-quill';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import 'quill/dist/quill.snow.css';
import './Editor.css';
interface EditorProps {
doc: Y.Doc;
provider: WebsocketProvider;
userName: string;
userColor: string;
}
const Editor: React.FC<EditorProps> = ({
doc,
provider,
userName,
userColor,
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const [quill, setQuill] = useState<Quill | null>(null);
useEffect(() => {
if (!editorRef.current) return;
// Initialize Quill
const quillInstance = new Quill(editorRef.current, {
theme: 'snow',
modules: {
toolbar: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ color: [] }, { background: [] }],
[{ align: [] }],
['link', 'image', 'code-block'],
['clean'],
],
cursors: true,
},
placeholder: 'Start typing...',
});
setQuill(quillInstance);
return () => {
quillInstance.disable();
};
}, []);
useEffect(() => {
if (!quill || !doc || !provider) return;
// Get or create Y.Text type
const yText = doc.getText('quill');
// Create Y.js binding
const binding = new QuillBinding(yText, quill, provider.awareness);
// Set local user info
provider.awareness.setLocalStateField('user', {
name: userName,
color: userColor,
});
return () => {
binding.destroy();
};
}, [quill, doc, provider, userName, userColor]);
return (
<div className="editor-container">
<div ref={editorRef} className="editor" />
</div>
);
};
export default Editor;
Editor Styles
/* src/components/Editor.css */
.editor-container {
width: 100%;
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.editor {
min-height: 500px;
font-size: 16px;
line-height: 1.6;
}
.ql-container {
font-size: 16px;
font-family: 'Inter', 'Segoe UI', sans-serif;
}
.ql-editor {
min-height: 500px;
padding: 40px;
}
.ql-toolbar {
border: none;
border-bottom: 1px solid #e1e4e8;
background: #f6f8fa;
padding: 12px;
}
.ql-editor.ql-blank::before {
color: #a0aec0;
font-style: italic;
}
/* Custom cursor styles */
.ql-cursor {
position: absolute;
width: 2px;
pointer-events: none;
z-index: 10;
}
.ql-cursor-flag {
position: absolute;
bottom: 100%;
left: -8px;
padding: 2px 6px;
font-size: 11px;
font-weight: 600;
border-radius: 3px;
white-space: nowrap;
color: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.ql-cursor-caret {
position: absolute;
width: 2px;
height: 1em;
background: currentColor;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
/* Selection styles */
.ql-cursor-selection {
position: absolute;
background-color: currentColor;
opacity: 0.3;
pointer-events: none;
}
User List Component
// src/components/UserList.tsx
import React from 'react';
import { UserState } from '../hooks/useAwareness';
import './UserList.css';
interface UserListProps {
users: Map<number, UserState>;
currentUser: UserState;
}
const UserList: React.FC<UserListProps> = ({ users, currentUser }) => {
return (
<div className="user-list">
<h3 className="user-list-title">Active Users ({users.size + 1})</h3>
<div className="user-list-items">
{/* Current user */}
<div className="user-item">
<div
className="user-avatar"
style={{ backgroundColor: currentUser.color }}
>
{currentUser.name.charAt(0).toUpperCase()}
</div>
<span className="user-name">{currentUser.name} (You)</span>
</div>
{/* Remote users */}
{Array.from(users.entries()).map(([clientId, user]) => (
<div key={clientId} className="user-item">
<div
className="user-avatar"
style={{ backgroundColor: user.color }}
>
{user.name.charAt(0).toUpperCase()}
</div>
<span className="user-name">{user.name}</span>
</div>
))}
</div>
</div>
);
};
export default UserList;
User List Styles
/* src/components/UserList.css */
.user-list {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 250px;
}
.user-list-title {
font-size: 14px;
font-weight: 600;
color: #2d3748;
margin-bottom: 16px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.user-list-items {
display: flex;
flex-direction: column;
gap: 12px;
}
.user-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
border-radius: 6px;
transition: background-color 0.2s;
}
.user-item:hover {
background-color: #f7fafc;
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
}
.user-name {
font-size: 14px;
color: #4a5568;
font-weight: 500;
}
Main Application Component
// src/App.tsx
import React, { useState, useEffect } from 'react';
import Editor from './components/Editor';
import UserList from './components/UserList';
import { useYDoc } from './hooks/useYDoc';
import { useAwareness } from './hooks/useAwareness';
import { getRandomColor } from './utils/colors';
import './App.css';
const App: React.FC = () => {
const [userName, setUserName] = useState('');
const [userColor] = useState(getRandomColor());
const [roomName, setRoomName] = useState('');
const [joined, setJoined] = useState(false);
const ydoc = useYDoc({
roomName: joined ? roomName : '',
serverUrl: 'ws://localhost:4000',
});
const users = useAwareness(
ydoc?.provider || null,
{ name: userName, color: userColor }
);
const handleJoin = (e: React.FormEvent) => {
e.preventDefault();
if (userName.trim() && roomName.trim()) {
setJoined(true);
}
};
if (!joined) {
return (
<div className="app">
<div className="join-container">
<div className="join-card">
<h1 className="join-title">Collaborative Editor</h1>
<p className="join-subtitle">
Join a room to start collaborating in real-time
</p>
<form onSubmit={handleJoin} className="join-form">
<div className="form-group">
<label htmlFor="userName">Your Name</label>
<input
id="userName"
type="text"
value={userName}
onChange={(e) => setUserName(e.target.value)}
placeholder="Enter your name"
required
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="roomName">Room Name</label>
<input
id="roomName"
type="text"
value={roomName}
onChange={(e) => setRoomName(e.target.value)}
placeholder="Enter room name"
required
/>
</div>
<button type="submit" className="join-button">
Join Room
</button>
</form>
</div>
</div>
</div>
);
}
if (!ydoc || !ydoc.synced) {
return (
<div className="app">
<div className="loading">
<div className="spinner" />
<p>Connecting to room...</p>
</div>
</div>
);
}
return (
<div className="app">
<header className="app-header">
<h1 className="app-title">Collaborative Editor</h1>
<div className="room-info">
<span className="room-label">Room:</span>
<span className="room-name">{roomName}</span>
</div>
</header>
<div className="app-content">
<div className="editor-section">
<Editor
doc={ydoc.doc}
provider={ydoc.provider}
userName={userName}
userColor={userColor}
/>
</div>
<aside className="sidebar">
<UserList
users={users}
currentUser={{ name: userName, color: userColor }}
/>
</aside>
</div>
</div>
);
};
export default App;
Application Styles
/* src/App.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.app {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
/* Join Screen */
.join-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.join-card {
background: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 400px;
width: 100%;
}
.join-title {
font-size: 28px;
font-weight: 700;
color: #2d3748;
margin-bottom: 8px;
text-align: center;
}
.join-subtitle {
font-size: 14px;
color: #718096;
text-align: center;
margin-bottom: 32px;
}
.join-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 600;
color: #4a5568;
}
.form-group input {
padding: 12px 16px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.join-button {
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.join-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
.join-button:active {
transform: translateY(0);
}
/* Loading State */
.loading {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
gap: 20px;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* App Header */
.app-header {
background: white;
padding: 16px 32px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.app-title {
font-size: 20px;
font-weight: 700;
color: #2d3748;
}
.room-info {
display: flex;
align-items: center;
gap: 8px;
}
.room-label {
font-size: 14px;
color: #718096;
font-weight: 500;
}
.room-name {
font-size: 14px;
color: #2d3748;
font-weight: 600;
background: #edf2f7;
padding: 4px 12px;
border-radius: 6px;
}
/* App Content */
.app-content {
display: flex;
gap: 24px;
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.editor-section {
flex: 1;
min-width: 0;
}
.sidebar {
width: 280px;
flex-shrink: 0;
}
/* Responsive Design */
@media (max-width: 1024px) {
.app-content {
flex-direction: column;
}
.sidebar {
width: 100%;
}
}
@media (max-width: 640px) {
.app-header {
flex-direction: column;
gap: 12px;
padding: 16px;
}
.app-content {
padding: 16px;
}
.join-card {
padding: 32px 24px;
}
}
Advanced Features
Adding Document Version History
// src/hooks/useVersionHistory.ts
import { useEffect, useState } from 'react';
import * as Y from 'yjs';
interface Version {
id: string;
timestamp: number;
snapshot: Uint8Array;
label?: string;
}
export const useVersionHistory = (doc: Y.Doc) => {
const [versions, setVersions] = useState<Version[]>([]);
const createVersion = (label?: string) => {
const snapshot = Y.encodeStateAsUpdate(doc);
const version: Version = {
id: Date.now().toString(),
timestamp: Date.now(),
snapshot,
label,
};
setVersions((prev) => [...prev, version]);
return version;
};
const restoreVersion = (versionId: string) => {
const version = versions.find((v) => v.id === versionId);
if (!version) return;
// Create new document from snapshot
const restoredDoc = new Y.Doc();
Y.applyUpdate(restoredDoc, version.snapshot);
// Apply to current document
const currentState = Y.encodeStateAsUpdate(doc);
const restoredState = Y.encodeStateAsUpdate(restoredDoc);
Y.applyUpdate(doc, restoredState);
};
return {
versions,
createVersion,
restoreVersion,
};
};
Implementing Commenting System
// src/components/Comments.tsx
import React, { useState } from 'react';
import * as Y from 'yjs';
interface Comment {
id: string;
author: string;
content: string;
timestamp: number;
position: number;
resolved: boolean;
}
interface CommentsProps {
doc: Y.Doc;
userName: string;
}
const Comments: React.FC<CommentsProps> = ({ doc, userName }) => {
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
// Get Y.Array for comments
const yComments = doc.getArray<Comment>('comments');
useEffect(() => {
const updateComments = () => {
setComments(yComments.toArray());
};
yComments.observe(updateComments);
updateComments();
return () => {
yComments.unobserve(updateComments);
};
}, [yComments]);
const addComment = (position: number) => {
if (!newComment.trim()) return;
const comment: Comment = {
id: Date.now().toString(),
author: userName,
content: newComment,
timestamp: Date.now(),
position,
resolved: false,
};
yComments.push([comment]);
setNewComment('');
};
const resolveComment = (commentId: string) => {
const index = comments.findIndex((c) => c.id === commentId);
if (index === -1) return;
const comment = comments[index];
yComments.delete(index, 1);
yComments.insert(index, [{ ...comment, resolved: true }]);
};
return (
<div className="comments-panel">
<h3>Comments</h3>
<div className="comments-list">
{comments.map((comment) => (
<div
key={comment.id}
className={`comment ${comment.resolved ? 'resolved' : ''}`}
>
<div className="comment-header">
<span className="comment-author">{comment.author}</span>
<span className="comment-time">
{new Date(comment.timestamp).toLocaleString()}
</span>
</div>
<p className="comment-content">{comment.content}</p>
{!comment.resolved && (
<button onClick={() => resolveComment(comment.id)}>
Resolve
</button>
)}
</div>
))}
</div>
</div>
);
};
export default Comments;
Real-time Cursor Tracking
// src/components/RemoteCursors.tsx
import React, { useEffect, useState } from 'react';
import { WebsocketProvider } from 'y-websocket';
import './RemoteCursors.css';
interface CursorPosition {
x: number;
y: number;
userName: string;
userColor: string;
}
interface RemoteCursorsProps {
provider: WebsocketProvider;
}
const RemoteCursors: React.FC<RemoteCursorsProps> = ({ provider }) => {
const [cursors, setCursors] = useState<Map<number, CursorPosition>>(
new Map()
);
useEffect(() => {
const awareness = provider.awareness;
const updateCursors = () => {
const newCursors = new Map<number, CursorPosition>();
awareness.getStates().forEach((state: any, clientId: number) => {
if (
clientId !== awareness.clientID &&
state.cursor &&
state.user
) {
newCursors.set(clientId, {
x: state.cursor.x,
y: state.cursor.y,
userName: state.user.name,
userColor: state.user.color,
});
}
});
setCursors(newCursors);
};
awareness.on('change', updateCursors);
return () => {
awareness.off('change', updateCursors);
};
}, [provider]);
return (
<div className="remote-cursors">
{Array.from(cursors.entries()).map(([clientId, cursor]) => (
<div
key={clientId}
className="remote-cursor"
style={{
left: cursor.x,
top: cursor.y,
borderColor: cursor.userColor,
}}
>
<div
className="cursor-flag"
style={{ backgroundColor: cursor.userColor }}
>
{cursor.userName}
</div>
</div>
))}
</div>
);
};
export default RemoteCursors;
Testing
Unit Tests for Y.js Integration
// src/__tests__/yjs.test.ts
import * as Y from 'yjs';
describe('Y.js Document Synchronization', () => {
it('should sync text between two documents', () => {
const doc1 = new Y.Doc();
const doc2 = new Y.Doc();
const text1 = doc1.getText('test');
const text2 = doc2.getText('test');
// Insert text in doc1
text1.insert(0, 'Hello World');
// Sync documents
const update = Y.encodeStateAsUpdate(doc1);
Y.applyUpdate(doc2, update);
// Both should have same content
expect(text2.toString()).toBe('Hello World');
});
it('should handle concurrent edits', () => {
const doc1 = new Y.Doc();
const doc2 = new Y.Doc();
const text1 = doc1.getText('test');
const text2 = doc2.getText('test');
// Initial sync
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1));
// Concurrent edits
text1.insert(0, 'Hello');
text2.insert(0, 'World');
// Sync both ways
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1));
Y.applyUpdate(doc1, Y.encodeStateAsUpdate(doc2));
// Both should converge to same state
expect(text1.toString()).toBe(text2.toString());
});
});
Integration Tests
// src/__tests__/editor.integration.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import App from '../App';
import { WebsocketProvider } from 'y-websocket';
// Mock WebSocket
jest.mock('y-websocket');
describe('Collaborative Editor Integration', () => {
beforeEach(() => {
(WebsocketProvider as jest.Mock).mockImplementation(() => ({
awareness: {
setLocalState: jest.fn(),
setLocalStateField: jest.fn(),
getStates: jest.fn(() => new Map()),
on: jest.fn(),
off: jest.fn(),
clientID: 1,
},
on: jest.fn(),
off: jest.fn(),
destroy: jest.fn(),
}));
});
it('should render join screen initially', () => {
render(<App />);
expect(screen.getByText('Collaborative Editor')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter your name')).toBeInTheDocument();
});
it('should join room with valid credentials', async () => {
render(<App />);
fireEvent.change(screen.getByPlaceholderText('Enter your name'), {
target: { value: 'John Doe' },
});
fireEvent.change(screen.getByPlaceholderText('Enter room name'), {
target: { value: 'test-room' },
});
fireEvent.click(screen.getByText('Join Room'));
await waitFor(() => {
expect(screen.getByText('Room:')).toBeInTheDocument();
});
});
});
Performance Optimization
Debouncing Updates
// src/utils/debounce.ts
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Usage in awareness updates
const debouncedAwarenessUpdate = debounce((awareness, cursor) => {
awareness.setLocalStateField('cursor', cursor);
}, 100);
Optimizing Render Performance
// src/components/Editor.tsx (optimized)
import React, { memo } from 'react';
const Editor = memo<EditorProps>(
({ doc, provider, userName, userColor }) => {
// ... component implementation
},
(prevProps, nextProps) => {
// Custom comparison
return (
prevProps.doc === nextProps.doc &&
prevProps.provider === nextProps.provider &&
prevProps.userName === nextProps.userName &&
prevProps.userColor === nextProps.userColor
);
}
);
export default Editor;
Deployment
Production Server Setup
// src/server/production-server.ts
import express from 'express';
import WebSocket from 'ws';
import http from 'http';
import { setupWSConnection } from 'y-websocket/bin/utils';
import compression from 'compression';
import helmet from 'helmet';
const app = express();
const PORT = process.env.PORT || 4000;
// Security middleware
app.use(helmet());
app.use(compression());
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
// Rate limiting
const connectionCounts = new Map<string, number>();
wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => {
const ip = req.socket.remoteAddress || 'unknown';
// Simple rate limiting
const count = connectionCounts.get(ip) || 0;
if (count > 10) {
ws.close(1008, 'Too many connections');
return;
}
connectionCounts.set(ip, count + 1);
setupWSConnection(ws, req, { gc: true });
ws.on('close', () => {
connectionCounts.set(ip, (connectionCounts.get(ip) || 1) - 1);
});
});
server.listen(PORT, () => {
console.log(`Production server running on port ${PORT}`);
});
Docker Configuration
# Dockerfile
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY yarn.lock ./
# Install dependencies
RUN yarn install --production
# Copy source code
COPY . .
# Build application
RUN yarn build
# Expose port
EXPOSE 4000
# Start server
CMD ["node", "dist/server/production-server.js"]
Docker Compose
# docker-compose.yml
version: '3.8'
services:
websocket-server:
build: .
ports:
- "4000:4000"
environment:
- NODE_ENV=production
- PORT=4000
volumes:
- ./db:/app/db
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./build:/usr/share/nginx/html
depends_on:
- websocket-server
restart: unless-stopped
Best Practices
1. Error Handling
// Wrap Y.js operations in try-catch
try {
const text = doc.getText('content');
text.insert(0, 'Hello');
} catch (error) {
console.error('Y.js operation failed:', error);
// Show user-friendly error message
}
2. Connection Management
// Monitor connection status
provider.on('status', ({ status }: { status: string }) => {
if (status === 'disconnected') {
showNotification('Connection lost. Reconnecting...');
} else if (status === 'connected') {
showNotification('Connected!');
}
});
3. Data Validation
// Validate awareness states
awareness.on('change', () => {
awareness.getStates().forEach((state, clientId) => {
if (!isValidUserState(state)) {
console.warn(`Invalid state from client ${clientId}`);
// Handle invalid state
}
});
});
4. Performance Monitoring
// Track sync performance
let syncStart = Date.now();
provider.on('sync', (synced: boolean) => {
if (synced) {
const syncDuration = Date.now() - syncStart;
console.log(`Sync completed in ${syncDuration}ms`);
} else {
syncStart = Date.now();
}
});
Conclusion
Building a real-time collaborative editor with React and Y.js provides a powerful foundation for modern collaborative applications. Y.js's CRDT implementation handles the complex aspects of conflict resolution and synchronization, allowing you to focus on building great user experiences.
Key Takeaways
- CRDTs Simplify Collaboration: Y.js handles conflict resolution automatically
- Network Agnostic: Works with WebSocket, WebRTC, or custom transports
- Rich Ecosystem: Integrates with popular editors and frameworks
- Production Ready: Includes persistence, versioning, and awareness
- Performance: Optimized for large documents and many concurrent users
Next Steps
- Add Authentication: Implement user authentication and authorization
- Enhanced Presence: Add video chat or voice capabilities
- Export/Import: Support for multiple document formats
- Mobile Support: Build React Native version
- Analytics: Track usage patterns and performance metrics
- Offline Mode: Implement offline-first architecture
Resources
- Y.js Documentation
- Y.js GitHub Repository
- CRDT Research Papers
- Quill Documentation
- WebSocket Protocol
The collaborative editor we've built demonstrates how modern web technologies can create seamless multi-user experiences. With Y.js handling the complexity of distributed state management, you can build sophisticated collaborative applications that rival commercial offerings like Google Docs or Notion.
Happy coding! 🚀
Want to see more tutorials on building collaborative applications? Follow me for in-depth guides on modern web development and real-time systems.