Building a Real-Time Collaborative Editor with React and Y.js

ReactJS, Node.js|OCTOBER 15, 2025|2 VIEWS
Create Google Docs-like collaborative editing experiences with Y.js CRDT technology, WebSocket synchronization, and React integration

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:

  1. Commutativity: Operations can be applied in any order
  2. Associativity: Grouping operations doesn't affect the result
  3. 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

  1. CRDTs Simplify Collaboration: Y.js handles conflict resolution automatically
  2. Network Agnostic: Works with WebSocket, WebRTC, or custom transports
  3. Rich Ecosystem: Integrates with popular editors and frameworks
  4. Production Ready: Includes persistence, versioning, and awareness
  5. 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

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.