CRDT (Collaborative Editing)

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

CRDT vs RTDB

CRDT is best for collaborative editing where multiple users edit simultaneously. For structured data with queries (todos, settings, lists), see RTDB. Compare all storage types in Data Storage & Access.

Installation

pnpm add yjs y-websocket

Quick Start

import * as cloudillo from '@cloudillo/core'
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/core'

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