name: yjs-editors description: > 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. type: core library: durable-streams library_version: "0.2.3" requires: - yjs-getting-started sources: - "durable-streams/durable-streams:packages/y-durable-streams/src/yjs-provider.ts" - "durable-streams/durable-streams:examples/yjs-demo/src/routes/room.$roomId.tsx" - "durable-streams/durable-streams:examples/yjs-demo/src/components/yjs-provider.tsx"
This skill builds on durable-streams/yjs-getting-started. Read it first for install and server setup.
Durable Streams — Editor Integrations
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.
React lifecycle pattern (shared by all editors)
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)
// ...
}
Why connect: false is required
The 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), andstartUpdatesStreamall fire before React'suseEffectruns- The
syncedevent can fire before any listener is attached - React strict mode double-renders make this race worse — the first render's provider is destroyed, and the event is lost
With connect: false, the provider is inert until p.connect() is called
explicitly — after all listeners are attached. No race, no missed events.
Why doc/awareness are in 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 |
Why not useMemo
useMemo 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.
Multi-document navigation
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.
SSR requirement
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,
})
Sharing doc/awareness via Context (multi-consumer apps)
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.
TipTap v3
Install
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.
Editor setup
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 conflictsCollaborationCaretuses a conditional spread becauseproviderisnullon first render (before the effect). The[provider]dep array onuseEditorrecreates the editor when the provider arrives.- The
documentoption takes theY.Docdirectly — TipTap creates theY.XmlFragmentinternally
Required CSS for collaboration carets
The 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 stylesheet:
.collaboration-carets__caret {
border-left: 1px solid;
border-right: 1px solid;
margin-left: -1px;
margin-right: -1px;
pointer-events: none;
position: relative;
word-break: normal;
}
.collaboration-carets__label {
border-radius: 3px 3px 3px 0;
color: #0d0d0d;
font-size: 12px;
font-style: normal;
font-weight: 600;
left: -1px;
line-height: normal;
padding: 0.1rem 0.3rem;
position: absolute;
top: -1.4em;
user-select: none;
white-space: nowrap;
}
The class names are collaboration-carets__caret and
collaboration-carets__label (plural carets, not "cursor"). The border
and background colors are set inline by the extension's default render
function using each user's color field — the CSS above only handles
positioning and sizing.
For dark themes, change the label color to match your foreground
(e.g. color: #1b1b1f for dark-on-light labels).
See: https://tiptap.dev/docs/editor/extensions/functionality/collaboration-cursor
CodeMirror 6
Install
npm install codemirror @codemirror/state @codemirror/view y-codemirror.next
Editor setup
Using the shared lifecycle pattern, add CodeMirror via a ref:
import { EditorView, basicSetup } from "codemirror"
import { EditorState } from "@codemirror/state"
import { yCollab } from "y-codemirror.next"
// Inside CollabEditor component, after the shared lifecycle code:
const editorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!editorRef.current || !synced) return
const ytext = doc.getText("content")
const state = EditorState.create({
doc: ytext.toString(),
extensions: [
basicSetup,
EditorView.lineWrapping,
yCollab(ytext, awareness),
],
})
const view = new EditorView({ state, parent: editorRef.current })
return () => view.destroy()
}, [synced, doc, awareness])
if (!synced) return <p>Connecting...</p>
return <div ref={editorRef} />
Key points:
yCollab(ytext, awareness)handles both document sync and cursor rendering- Uses
Y.Text(notY.XmlFragmentlike TipTap) - Editor is created after
syncedto avoid rendering stale empty state
Other editors
BlockNote — built on TipTap. Use the same packages and pattern as TipTap
above. BlockNote's useCreateBlockNote accepts a collaboration option
with provider and fragment fields.
Lexical — use @lexical/yjs with CollaborationPlugin. Pass the
YjsProvider as the provider. Requires ssr: false like all Yjs editors.
Common Mistakes
CRITICAL Installing @tiptap/extension-collaboration-cursor (TipTap)
Wrong:
npm install @tiptap/extension-collaboration-cursor
Correct:
npm install @tiptap/extension-collaboration-caret
The -cursor package is a broken v3 stub. It imports from y-prosemirror
which uses a different ySyncPluginKey singleton than TipTap v3's internal
@tiptap/y-tiptap. Crashes with TypeError: Cannot read properties of undefined (reading 'doc').
Source: TipTap v3 migration, @tiptap/extension-collaboration-caret package
CRITICAL Auto-connecting provider without listeners
Wrong:
// Provider auto-connects in constructor — synced event fires before
// useEffect attaches the listener → stuck on "Connecting..." forever
const [provider] = useState(
() => new YjsProvider({ doc, baseUrl, docId, awareness })
)
useEffect(() => {
provider.on("synced", (s) => {
if (s) setSynced(true)
})
// TOO LATE — synced already fired during construction
}, [provider])
Correct: Use the useEffect + connect: false pattern from the lifecycle
section above. Listeners are attached before connect() is called.
This is the #1 cause of "stuck Connecting" in agent-built apps. The provider
connects, syncs, emits synced: true, but no listener is attached yet.
React's useEffect runs after the render cycle, by which time the async
connection has already completed.
HIGH Using useMemo for Y.Doc or Awareness (all editors)
Wrong:
const ydoc = useMemo(() => new Y.Doc(), [])
const awareness = useMemo(() => new Awareness(ydoc), [ydoc])
Correct: Use useState(() => ...) lazy initializers.
useMemo is a caching hint. React can evict and recreate the value without
calling cleanup. Leaked Y.Doc and Awareness instances accumulate
listeners and connections.
HIGH Not disabling SSR (all editors)
Wrong: Using YjsProvider in a server-rendered route.
Correct: Set ssr: false on the route. YjsProvider uses fetch/EventSource
which don't exist server-side.
MEDIUM Not keying component on docId for multi-document navigation
Wrong:
<CollabEditor docId={docId} />
Correct:
<CollabEditor key={docId} docId={docId} />
Without key, React reuses the component. The old ydoc/provider persist
with stale document data. Keying forces full unmount → remount with fresh
Yjs objects.
See also
- yjs-getting-started — Install and server setup
- yjs-sync — Provider options, events, error recovery
- yjs-server — Production deployment