CRDT (Collaborative Editing)

CRDT - Conflict-Free Replicated Data Types

Cloudillo uses Yjs for real-time collaborative editing with CRDT (Conflict-Free Replicated Data Types).

What are CRDTs?

CRDTs enable multiple users to edit the same document simultaneously without conflicts. Changes from different users are automatically merged in a consistent way.

Benefits:

  • Offline-first - Works without internet connection
  • Conflict-free - All changes merge automatically
  • Real-time - See other users’ changes instantly
  • Efficient - Only sends deltas, not entire documents

Installation

pnpm add yjs y-websocket

Quick Start

import * as cloudillo from '@cloudillo/base'
import * as Y from 'yjs'

// Initialize
await cloudillo.init('my-app')

// Create Yjs document
const yDoc = new Y.Doc()

// Open collaborative document
const { provider } = await cloudillo.openYDoc(yDoc, 'my-document-id')

// Use shared text
const yText = yDoc.getText('content')
yText.insert(0, 'Hello, collaborative world!')

// Listen for changes
yText.observe(() => {
  console.log('Text updated:', yText.toString())
})

Shared Types

Yjs provides several shared data types:

YText - Shared Text

Best for plain text or rich text content.

const yText = yDoc.getText('content')

// Insert text
yText.insert(0, 'Hello ')
yText.insert(6, 'world!')

// Delete text
yText.delete(0, 5) // Delete 5 characters from position 0

// Format text (for rich text)
yText.format(0, 5, { bold: true })

// Get text content
console.log(yText.toString()) // "world!"

// Observe changes
yText.observe((event) => {
  event.changes.delta.forEach(change => {
    if (change.insert) {
      console.log('Inserted:', change.insert)
    }
    if (change.delete) {
      console.log('Deleted', change.delete, 'characters')
    }
  })
})

YMap - Shared Object

Best for key-value data like form fields or settings.

const yMap = yDoc.getMap('settings')

// Set values
yMap.set('theme', 'dark')
yMap.set('fontSize', 14)
yMap.set('notifications', true)

// Get values
console.log(yMap.get('theme')) // "dark"

// Delete keys
yMap.delete('fontSize')

// Check existence
console.log(yMap.has('theme')) // true

// Iterate
yMap.forEach((value, key) => {
  console.log(`${key}: ${value}`)
})

// Observe changes
yMap.observe((event) => {
  event.changes.keys.forEach((change, key) => {
    if (change.action === 'add') {
      console.log(`Added ${key}:`, yMap.get(key))
    } else if (change.action === 'update') {
      console.log(`Updated ${key}:`, yMap.get(key))
    } else if (change.action === 'delete') {
      console.log(`Deleted ${key}`)
    }
  })
})

YArray - Shared Array

Best for lists like todos, comments, or items.

const yArray = yDoc.getArray('todos')

// Push items
yArray.push([
  { title: 'Task 1', done: false },
  { title: 'Task 2', done: false }
])

// Insert at position
yArray.insert(0, [{ title: 'Urgent task', done: false }])

// Delete
yArray.delete(0, 1) // Delete 1 item at position 0

// Get items
console.log(yArray.get(0)) // First item
console.log(yArray.toArray()) // All items as array

// Iterate
yArray.forEach((item, index) => {
  console.log(index, item)
})

// Observe changes
yArray.observe((event) => {
  console.log('Array changed:', event.changes)
})

YXmlFragment - Shared XML/HTML

Best for rich text editors with complex formatting.

const yXml = yDoc.getXmlFragment('document')

// Create elements
const paragraph = new Y.XmlElement('p')
paragraph.setAttribute('class', 'text')
paragraph.insert(0, [new Y.XmlText('Hello world')])

yXml.insert(0, [paragraph])

Awareness

Awareness tracks user presence, cursors, and selections in real-time.

const { provider } = await cloudillo.openYDoc(yDoc, 'doc_123')

// Set local awareness state
provider.awareness.setLocalState({
  user: {
    name: cloudillo.name,
    idTag: cloudillo.idTag,
    color: '#ff6b6b'
  },
  cursor: {
    line: 10,
    column: 5
  },
  selection: {
    from: 100,
    to: 150
  }
})

// Listen for awareness changes
provider.awareness.on('change', () => {
  const states = provider.awareness.getStates()

  states.forEach((state, clientId) => {
    if (state.user) {
      console.log(`User ${state.user.name} at cursor ${state.cursor}`)
    }
  })
})

// Get specific client state
const clientId = provider.awareness.clientID
const state = provider.awareness.getStates().get(clientId)

Editor Bindings

Yjs provides bindings for popular editors:

Quill (Rich Text)

pnpm add quill y-quill
import Quill from 'quill'
import { QuillBinding } from 'y-quill'

// Create Yjs document
const yDoc = new Y.Doc()
const { provider } = await cloudillo.openYDoc(yDoc, 'doc_123')

const yText = yDoc.getText('content')

// Create Quill editor
const editor = new Quill('#editor', {
  theme: 'snow'
})

// Bind Yjs to Quill
const binding = new QuillBinding(yText, editor, provider.awareness)

// Quill now syncs with Yjs automatically

CodeMirror (Code Editor)

pnpm add codemirror y-codemirror
import { EditorView, basicSetup } from 'codemirror'
import { yCollab } from 'y-codemirror.next'

const yText = yDoc.getText('content')

const editor = new EditorView({
  extensions: [
    basicSetup,
    yCollab(yText, provider.awareness)
  ],
  parent: document.querySelector('#editor')
})

Monaco (VS Code Editor)

pnpm add monaco-editor y-monaco
import * as monaco from 'monaco-editor'
import { MonacoBinding } from 'y-monaco'

const yText = yDoc.getText('content')

const editor = monaco.editor.create(document.getElementById('editor'), {
  value: '',
  language: 'javascript'
})

const binding = new MonacoBinding(
  yText,
  editor.getModel(),
  new Set([editor]),
  provider.awareness
)

ProseMirror (Rich Text)

pnpm add prosemirror-view prosemirror-state y-prosemirror
import { EditorState } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from 'y-prosemirror'

const yXml = yDoc.getXmlFragment('prosemirror')

const state = EditorState.create({
  schema,
  plugins: [
    ySyncPlugin(yXml),
    yCursorPlugin(provider.awareness),
    yUndoPlugin()
  ]
})

const view = new EditorView(document.querySelector('#editor'), {
  state
})

Offline Support

Yjs documents work offline and sync when reconnected.

import { IndexeddbPersistence } from 'y-indexeddb'

const yDoc = new Y.Doc()

// Persist to IndexedDB
const indexeddbProvider = new IndexeddbPersistence('my-doc-id', yDoc)

indexeddbProvider.on('synced', () => {
  console.log('Loaded from IndexedDB')
})

// Also connect to server
const { provider } = await cloudillo.openYDoc(yDoc, 'my-doc-id')

// Now works offline with local persistence
// Syncs to server when connection available

Transactions

Group multiple changes into a single transaction:

yDoc.transact(() => {
  const yText = yDoc.getText('content')
  yText.insert(0, 'Hello ')
  yText.insert(6, 'world!')
  yText.format(0, 11, { bold: true })
})

// All changes sync as one update
// Only one observer event fired

Undo/Redo

import { UndoManager } from 'yjs'

const yText = yDoc.getText('content')
const undoManager = new UndoManager(yText)

// Make changes
yText.insert(0, 'Hello')

// Undo
undoManager.undo()

// Redo
undoManager.redo()

// Track who made changes
undoManager.on('stack-item-added', (event) => {
  console.log('Change by:', event.origin)
})

Document Lifecycle

// Create document
const yDoc = new Y.Doc()

// Open collaborative connection
const { provider } = await cloudillo.openYDoc(yDoc, 'doc_123')

// Use document...

// Close connection
provider.destroy()

// Destroy document
yDoc.destroy()

Best Practices

1. Use Subdocs for Large Documents

const yDoc = new Y.Doc()
const yMap = yDoc.getMap('pages')

// Create subdocument for each page
const page1 = new Y.Doc()
yMap.set('page1', page1)

const page1Text = page1.getText('content')
page1Text.insert(0, 'Page 1 content')

2. Batch Operations in Transactions

// ✅ Single transaction
yDoc.transact(() => {
  for (let i = 0; i < 100; i++) {
    yText.insert(i, 'x')
  }
})

// ❌ Many transactions
for (let i = 0; i < 100; i++) {
  yText.insert(i, 'x') // Sends 100 updates!
}

3. Clean Up Observers

// Add observer
const observer = (event) => {
  console.log('Changed:', event)
}
yText.observe(observer)

// Remove observer when done
yText.unobserve(observer)

4. Handle Connection State

provider.on('status', ({ status }) => {
  if (status === 'connected') {
    setConnectionStatus('online')
  } else {
    setConnectionStatus('offline')
  }
})

React Example

Complete collaborative editor in React:

import { useEffect, useState } from 'react'
import { useApi } from '@cloudillo/react'
import * as Y from 'yjs'
import * as cloudillo from '@cloudillo/base'

function CollaborativeEditor({ docId }) {
  const [yDoc, setYDoc] = useState(null)
  const [provider, setProvider] = useState(null)
  const [connected, setConnected] = useState(false)

  useEffect(() => {
    const doc = new Y.Doc()
    setYDoc(doc)

    cloudillo.openYDoc(doc, docId).then(({ provider: p }) => {
      setProvider(p)

      p.on('status', ({ status }) => {
        setConnected(status === 'connected')
      })
    })

    return () => {
      provider?.destroy()
      doc.destroy()
    }
  }, [docId])

  if (!yDoc) return <div>Loading...</div>

  return (
    <div>
      <div className="status">
        {connected ? '🟢 Connected' : '🔴 Disconnected'}
      </div>
      <Editor yDoc={yDoc} provider={provider} />
    </div>
  )
}

See Also