| 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 }) {
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 }
})
const [provider, setProvider] = useState<YjsProvider | null>(null)
const [synced, setSynced] = useState(false)
useEffect(() => {
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,
})
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])
useEffect(() => {
return () => {
awareness.destroy()
doc.destroy()
}
}, [doc, awareness])
}
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), and
startUpdatesStream all fire before React's useEffect runs
- The
synced event 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.
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
const [{ doc, awareness }] = useState(() => {
const d = new Y.Doc()
const a = new Awareness(d)
a.setLocalState({ user: initialUser })
return { doc: d, awareness: a }
})
useEffect(
() => () => {
awareness.destroy()
doc.destroy()
},
[doc, awareness]
)
const [isLoading, setIsLoading] = useState(true)
const [isSynced, setIsSynced] = useState(false)
const [error, setError] = useState<Error | null>(null)
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,
})
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)
})
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 /> {}
<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"
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]
)
if (!synced) return <p>Connecting...</p>
return <EditorContent editor={editor} />
Key points:
undoRedo: false — Yjs has its own undo manager; StarterKit's conflicts
CollaborationCaret 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.
- The
document option takes the Y.Doc directly — TipTap creates the
Y.XmlFragment internally
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"
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 (not Y.XmlFragment like TipTap)
- Editor is created after
synced to 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:
const [provider] = useState(
() => new YjsProvider({ doc, baseUrl, docId, awareness })
)
useEffect(() => {
provider.on("synced", (s) => {
if (s) setSynced(true)
})
}, [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