Real-time Collaboration
Y.js CRDT and TipTap integration for conflict-free text editing.
Overview
On Book Pro uses Y.js (CRDT library) with TipTap for real-time collaborative rich text editing in the Notes feature.
Key Benefits:
- Conflict-free merging — Multiple users can edit simultaneously
- Offline support — Changes sync when reconnected
- Presence awareness — See who's editing in real-time
Architecture
┌─────────────────┐ ┌─────────────────┐
│ TipTap Editor │ │ TipTap Editor │
│ (User A) │ │ (User B) │
└────────┬────────┘ └────────┬────────┘
│ │
┌────▼────┐ ┌────▼────┐
│ Y.Doc │◄───────────►│ Y.Doc │
└────┬────┘ Sync └────┬────┘
│ │
└───────────┬───────────┘
▼
┌──────────────┐
│ Firestore │
│ (Persistence)│
└──────────────┘Y.js Setup
Creating a Y.Doc
typescript
import * as Y from 'yjs';
// Create a new Y.Doc for a note
const ydoc = new Y.Doc();
// Create a shared text type
const ytext = ydoc.getText('content');
// Create shared awareness (for presence)
const awareness = new Awareness(ydoc);Syncing with Firestore
typescript
import { doc, onSnapshot, setDoc } from 'firebase/firestore';
import * as Y from 'yjs';
import { fromUint8Array, toUint8Array } from 'js-base64';
// Save Y.Doc state to Firestore
const saveToFirestore = async (noteId: string, ydoc: Y.Doc) => {
const state = Y.encodeStateAsUpdate(ydoc);
const base64 = fromUint8Array(state);
await setDoc(doc(db, `notes/${noteId}`), {
ydocState: base64,
updatedAt: serverTimestamp(),
});
};
// Load Y.Doc state from Firestore
const loadFromFirestore = async (noteId: string, ydoc: Y.Doc) => {
const snapshot = await getDoc(doc(db, `notes/${noteId}`));
if (snapshot.exists()) {
const base64 = snapshot.data().ydocState;
const state = toUint8Array(base64);
Y.applyUpdate(ydoc, state);
}
};TipTap Integration
Editor Setup
typescript
import { useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
const editor = useEditor({
extensions: [
StarterKit,
Collaboration.configure({
document: ydoc,
}),
CollaborationCursor.configure({
provider: awarenessProvider,
user: {
name: currentUser.displayName,
color: '#f59e0b',
},
}),
],
});Editor Component
tsx
import { EditorContent } from '@tiptap/react';
export const CollaborativeEditor = ({ noteId }: { noteId: string }) => {
const { ydoc, awareness } = useYDoc(noteId);
const editor = useEditor({
extensions: [
StarterKit,
Collaboration.configure({ document: ydoc }),
CollaborationCursor.configure({
provider: awareness,
user: { name: 'User', color: '#f59e0b' },
}),
],
});
return (
<div className="prose max-w-none">
<EditorContent editor={editor} />
</div>
);
};Presence Awareness
Show who's currently editing:
typescript
import { Awareness } from 'y-protocols/awareness';
// Update local user state
awareness.setLocalState({
user: {
name: currentUser.displayName,
color: '#f59e0b',
cursor: { anchor: 0, head: 0 },
},
});
// Listen for other users
awareness.on('change', () => {
const states = awareness.getStates();
states.forEach((state, clientId) => {
console.log(`User ${state.user?.name} at client ${clientId}`);
});
});Displaying Cursors
TipTap's CollaborationCursor extension renders colored cursors automatically. Style them with CSS:
css
.collaboration-cursor__caret {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid;
border-right: 1px solid;
word-break: normal;
pointer-events: none;
}
.collaboration-cursor__label {
position: absolute;
top: -1.4em;
left: -1px;
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: normal;
padding: 0.1rem 0.3rem;
border-radius: 3px 3px 3px 0;
white-space: nowrap;
}Conflict Resolution
Y.js uses CRDTs (Conflict-free Replicated Data Types) to automatically merge concurrent edits:
typescript
// User A types "Hello"
ytext.insert(0, 'Hello');
// User B types "World" at the same time
ytext.insert(0, 'World');
// Y.js automatically merges to "HelloWorld" or "WorldHello"
// based on consistent ordering rulesNo Manual Merge Needed
Unlike traditional sync:
- ❌ No merge conflicts
- ❌ No last-write-wins data loss
- ✅ All changes preserved
Offline Support
Y.js works offline automatically:
- User edits offline — Changes stored in Y.Doc
- User reconnects — Y.Doc syncs with Firestore
- Merging happens automatically — CRDT ensures consistency
typescript
// Check online status
window.addEventListener('online', () => {
syncToFirestore(noteId, ydoc);
});Best Practices
- One Y.Doc per note — Don't share docs across unrelated content
- Debounce Firestore saves — Don't save on every keystroke
- Clean up awareness — Remove local state on unmount
- Handle reconnection — Re-sync when coming back online
Further Reading
Last updated: January 2026