---
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.

```typescript
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), 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:

```tsx
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.

```tsx
// 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.

```tsx
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:

```tsx
<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

```bash
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:

```tsx
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 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:

```css
.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

```bash
npm install codemirror @codemirror/state @codemirror/view y-codemirror.next
```

### Editor setup

Using the shared lifecycle pattern, add CodeMirror via a ref:

```tsx
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` (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:

```bash
npm install @tiptap/extension-collaboration-cursor
```

Correct:

```bash
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:

```tsx
// 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:

```tsx
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:

```tsx
<CollabEditor docId={docId} />
```

Correct:

```tsx
<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](../yjs-getting-started/SKILL.md) — Install and server setup
- [yjs-sync](../yjs-sync/SKILL.md) — Provider options, events, error recovery
- [yjs-server](../yjs-server/SKILL.md) — Production deployment
