Integrate Yjs collaborative editing with TipTap v3 and CodeMirror 6 over durable streams. Canonical React pattern: doc+awareness in useState, provider in useEffect with connect:false (listeners before connect). TipTap: Collaboration + CollaborationCaret extensions, -caret not -cursor package. CodeMirror: yCollab binding. Covers awareness wiring, multi-document navigation with key={docId}, SSR ssr:false requirement. Critical anti-patterns that crash agents documented.
This skill builds on durable-streams/yjs-getting-started. Read it first for install and server setup.
Wire Yjs + YjsProvider into rich-text and code editors. Both integrations share the same React lifecycle pattern — the editor-specific code is just the binding setup.
All editor integrations MUST use this pattern.
Key principle: Doc and awareness are created once via useState (stable
references). The provider is created in useEffect with connect: false so
that event listeners are attached BEFORE the first network request. This
prevents the race condition where synced fires between construction and
listener attachment.
import { useState, useEffect, useRef } from "react"
import { YjsProvider } from "@durable-streams/y-durable-streams"
import * as Y from "yjs"
import { Awareness } from "y-protocols/awareness"
function CollabEditor({ docId }: { docId: string }) {
// 1. Doc + awareness: stable, created once via useState lazy init.
// Use setLocalState (not setLocalStateField) because a new
// Awareness starts with null state.
const [{ doc, awareness }] = useState(() => {
const d = new Y.Doc()
const aw = new Awareness(d)
aw.setLocalState({
user: {
name: localStorage.getItem("userName") || "Anonymous",
color: localStorage.getItem("userColor") || "#d0bcff",
},
})
return { doc: d, awareness: aw }
})
// 2. Provider: created in useEffect with connect:false.
// Listeners are attached BEFORE connect() so events are never missed.
const [provider, setProvider] = useState<YjsProvider | null>(null)
const [synced, setSynced] = useState(false)
useEffect(() => {
// Re-set awareness if React strict mode cleanup cleared it
if (awareness.getLocalState() === null) {
awareness.setLocalState({
user: {
name: localStorage.getItem("userName") || "Anonymous",
color: localStorage.getItem("userColor") || "#d0bcff",
},
})
}
const p = new YjsProvider({
doc,
baseUrl: "https://your-server.com/v1/yjs/my-service",
docId,
awareness,
connect: false, // listeners first, then connect
})
// Attach listeners BEFORE connect()
p.on("synced", (s: boolean) => {
if (s) setSynced(true)
})
p.on("error", (err: Error) => {
console.error("[YjsProvider] error:", err)
})
setProvider(p)
p.connect()
return () => {
p.destroy()
setProvider(null)
}
}, [doc, awareness, docId])
// 3. Clean up doc + awareness on component unmount
useEffect(() => {
return () => {
awareness.destroy()
doc.destroy()
}
}, [doc, awareness])
// 4. Editor setup goes here (see TipTap / CodeMirror sections below)
// ...
}
connect: false is requiredThe provider starts its async connection flow immediately in the constructor
when connect is true (the default). This means:
ensureDocument (PUT), discoverSnapshot (GET with 307 handling), and
startUpdatesStream all fire before React's useEffect runssynced event can fire before any listener is attachedWith connect: false, the provider is inert until p.connect() is called
explicitly — after all listeners are attached. No race, no missed events.
useState but provider is in useEffect| Doc + Awareness | Provider | |
|---|---|---|
| Created via | useState(() => ...) | useEffect + connect:false |
| Stable across re-renders | Yes (useState is stable) | Recreated when docId changes |
| Event listeners | None needed before creation | Must be attached before connect |
| Cleanup | Separate unmount effect | Effect cleanup destroys it |
useMemouseMemo is a caching hint, not a lifecycle primitive. React can evict and
recreate the value without cleanup. Y.Doc and Awareness need explicit
.destroy(). useState lazy init + useEffect cleanup is the correct
primitive for objects with construction + destruction.
When navigating between documents, key the component on docId so React
fully unmounts and remounts it:
function DocPage() {
const { docId } = Route.useParams()
return <CollabEditor key={docId} docId={docId} />
}
Do NOT reuse ydoc/provider across documents — CRDTs are per-document.
Routes using YjsProvider MUST disable SSR. The provider uses fetch and
EventSource which don't exist server-side.
// TanStack Router
export const Route = createFileRoute("/doc/$docId")({
ssr: false,
component: DocPage,
})
When several sibling components need the same doc and awareness (an editor, a presence list, a save button), wrap them in a Context Provider instead of prop-drilling. The Provider owns the lifecycle; children consume via a hook.
import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { ReactNode } from "react"
import * as Y from "yjs"
import { Awareness } from "y-protocols/awareness"
import { YjsProvider } from "@durable-streams/y-durable-streams"
import type { YjsProviderStatus } from "@durable-streams/y-durable-streams"
interface YjsRoomContextValue {
doc: Y.Doc
awareness: Awareness
roomId: string
isLoading: boolean
isSynced: boolean
error: Error | null
setUsername: (name: string) => void
username: string
}
const YjsRoomContext = createContext<YjsRoomContextValue | null>(null)
export function useYjsRoom(): YjsRoomContextValue {
const ctx = useContext(YjsRoomContext)
if (!ctx) throw new Error("useYjsRoom must be used inside YjsRoomProvider")
return ctx
}
export function YjsRoomProvider({
roomId,
baseUrl,
initialUser,
children,
}: {
roomId: string
baseUrl: string
initialUser: { name: string; color: string; colorLight: string }
children: ReactNode
}) {
const [username, setUsernameState] = useState(initialUser.name)
const usernameRef = useRef(username)
usernameRef.current = username
// Doc + awareness: stable across renders, with initial local state so the
// first awareness broadcast already has the user info (no null-state flash).
const [{ doc, awareness }] = useState(() => {
const d = new Y.Doc()
const a = new Awareness(d)
a.setLocalState({ user: initialUser })
return { doc: d, awareness: a }
})
// Destroy doc + awareness on unmount
useEffect(
() => () => {
awareness.destroy()
doc.destroy()
},
[doc, awareness]
)
const [isLoading, setIsLoading] = useState(true)
const [isSynced, setIsSynced] = useState(false)
const [error, setError] = useState<Error | null>(null)
// Mutation path for username — merge into existing awareness state so
// other fields (cursor, selection) aren't clobbered.
const setUsername = (name: string) => {
setUsernameState(name)
const current = awareness.getLocalState() || {}
awareness.setLocalState({
...current,
user: { ...initialUser, name },
})
}
useEffect(() => {
const provider = new YjsProvider({
doc,
baseUrl,
docId: roomId,
awareness,
connect: false, // attach listeners BEFORE connecting
})
provider.on("synced", (s: boolean) => {
setIsSynced(s)
if (s) setIsLoading(false)
})
provider.on("status", (s: YjsProviderStatus) => {
if (s === "connected") setIsLoading(false)
})
provider.on("error", (err: Error) => {
setError(err)
setIsLoading(false)
})
// Strict Mode's effect cleanup may have wiped local state when the
// previous provider was destroyed. Re-seed before connecting so the
// first broadcast has user info (uses usernameRef, not the stale closure).
if (awareness.getLocalState() === null) {
awareness.setLocalState({
user: { ...initialUser, name: usernameRef.current },
})
}
provider.connect()
return () => provider.destroy()
}, [roomId, doc, awareness, baseUrl, initialUser])
return (
<YjsRoomContext.Provider
value={{
doc,
awareness,
roomId,
isLoading,
isSynced,
error,
setUsername,
username,
}}
>
{children}
</YjsRoomContext.Provider>
)
}
Usage — key the Provider on roomId so navigating between rooms fully tears down and rebuilds the CRDT:
<YjsRoomProvider
key={roomId}
roomId={roomId}
baseUrl={baseUrl}
initialUser={user}
>
<Editor /> {/* consumes via useYjsRoom() */}
<PresenceList />
<SaveButton />
</YjsRoomProvider>
Three things to notice: (1) status + synced + error events are all
attached before connect(), (2) the usernameRef is read at connect time
to survive Strict Mode's double-invocation cleanup, (3) setUsername
merges into existing local state instead of overwriting it.
npm install @tiptap/react @tiptap/starter-kit \
@tiptap/extension-collaboration @tiptap/extension-collaboration-caret
Do NOT install @tiptap/extension-collaboration-cursor — it's a broken
v3 stub that imports y-prosemirror (replaced by @tiptap/y-tiptap in v3).
Crashes with TypeError: Cannot read properties of undefined (reading 'doc').
Do NOT install y-prosemirror — TipTap v3 internalized it. Having both
creates duplicate ySyncPluginKey singletons that crash the editor.
Using the shared lifecycle pattern above, add the editor. Note: provider
starts as null and becomes non-null after the useEffect runs. Use a
conditional spread for CollaborationCaret and [provider] as a dep so
the editor recreates when the provider arrives:
import { useEditor, EditorContent } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Collaboration from "@tiptap/extension-collaboration"
import CollaborationCaret from "@tiptap/extension-collaboration-caret"
// Inside CollabEditor component, after the shared lifecycle code:
const editor = useEditor(
{
extensions: [
StarterKit.configure({ undoRedo: false }),
Collaboration.configure({ document: doc }),
...(provider
? [
CollaborationCaret.configure({
provider,
user: {
name: localStorage.getItem("userName") || "Anonymous",
color: localStorage.getItem("userColor") || "#d0bcff",
},
}),
]
: []),
],
editorProps: {
attributes: {
class: "prose max-w-none min-h-[60vh] focus:outline-none",
},
},
},
[provider] // recreate editor when provider becomes available
)
if (!synced) return <p>Connecting...</p>
return <EditorContent editor={editor} />
Key points:
undoRedo: false — Yjs has its own undo manager; StarterKit's conflictsCollaborationCaret uses a conditional spread because provider is
null on first render (before the effect). The [provider] dep array
on useEditor recreates the editor when the provider arrives.document option takes the Y.Doc directly — TipTap creates the
Y.XmlFragment internallyThe CollaborationCaret extension does not include default styles. Without the CSS below, carets render as unstyled inline elements that occupy the full line instead of appearing as thin cursor indicators. Add this to your global