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-websocketQuick 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-quillimport 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-codemirrorimport { 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-monacoimport * 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-prosemirrorimport { 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
- Getting Started - Initialize Cloudillo
- WebSocket API - CRDT protocol
- Files API - Create CRDT files
- Yjs Documentation - Official Yjs docs