useProtoDoc
useProtoDoc
Manages the complete lifecycle of a Y.js document. Handles reference counting, IndexedDB persistence, BroadcastChannel tab sync, server push, snapshot creation, and corruption detection/recovery.
You rarely need to call this directly — usePrototype calls it internally. Use useProtoDoc when you need direct access to the Y.Doc object or the sync state.
Signature
function useProtoDoc(
docKey: string,
options?: {
enableIndexedDB?: boolean // default: true
enableBroadcast?: boolean // default: true
skipAutoCleanup?: boolean // default: false
disableSync?: boolean // default: false — override global serverSync config
}
): UseProtoDocReturn
Return value
interface UseProtoDocReturn {
doc: Y.Doc
isReady: Ref<boolean> // true once IndexedDB has loaded
destroy: () => void // manually trigger cleanup (ref-counted)
}
Document caching
useProtoDoc maintains a module-level reference-counted cache. If two components call useProtoDoc('my-tool') simultaneously, they receive the same Y.Doc instance. The document is destroyed only when all consumers have unmounted.
// Component A and Component B share the same Y.Doc
const { doc: docA } = useProtoDoc('idea-123')
const { doc: docB } = useProtoDoc('idea-123')
console.log(docA === docB) // true
This is safe and intended — it enables multiple components to observe and modify the same document simultaneously, with Y.js merging changes automatically.
Lifecycle
Component mounts
│
▼
useProtoDoc('key')
│
├── Check cache: existing doc? → return cached + increment ref count
│
└── New doc:
├── new Y.Doc()
├── new IndexeddbPersistence('proto:key', doc)
│ └── loads stored data from browser DB
├── isReady = true ← when IndexedDB fires 'synced' event
├── new BroadcastChannel('proto:key')
│ └── notifies other tabs of updates
└── if serverSync enabled:
└── push doc state + create snapshot (30s debounce on updates)
Component unmounts
│
└── decrement ref count
└── if ref count === 0:
├── clear debounced server push timer
├── destroy BroadcastChannel
├── destroy IndexeddbPersistence
└── remove from cache
Server sync
Server sync is controlled by the protokit.serverSync option in nuxt.config.ts. When enabled, useProtoDoc pushes the document state every 30 seconds (debounced on changes):
Client Server (yjs-sync or compatible)
────── ───────────────────────────────
POST {baseUrl}/sync ──────────────► Stores binary Y.js updates
POST {baseUrl}/snapshots/create ───► Creates deduplicated JSON snapshot
Global config
// nuxt.config.ts
protokit: {
serverSync: true, // default — enabled, baseUrl: '/api/yjs'
serverSync: false, // disabled — zero HTTP calls made
serverSync: { baseUrl: '/api/yjs' }, // custom server base URL
}
Per-document override
Pass disableSync: true to suppress server sync for one specific document, regardless of global config. Useful for public demo tools or scratch pads that should not accumulate server snapshots:
// This doc never syncs to the server, even if serverSync: true globally
const { doc, isReady } = useProtoDoc('public-demo', { disableSync: true })
The document still persists to IndexedDB locally.
What happens when sync is disabled
- No
POST /api/yjs/synccalls are made - No
POST /api/yjs/snapshots/createcalls are made - If IndexedDB corruption occurs, the recovery modal shows only "Start fresh" (no server backup to restore from)
- Local IndexedDB + BroadcastChannel tab sync work normally
BroadcastChannel tab sync
When a doc is updated in one browser tab, useProtoDoc broadcasts the update to all other tabs via BroadcastChannel. Other tabs apply the update to their in-memory Y.Doc immediately — no round-trip to the server.
This means: open the same tool in two tabs, type in one — the other updates in real time, offline.
Readiness guard
Always wait for isReady before reading field values:
const { doc, isReady } = useProtoDoc('my-tool')
watch(isReady, (ready) => {
if (ready) {
const fieldsMap = doc.getMap('fields')
console.log('stored MRR:', fieldsMap.get('mrr'))
}
}, { immediate: true })
In templates, guard with v-if:
<ClientOnly>
<MyTool v-if="isReady" :doc="doc" />
<USkeleton v-else />
</ClientOnly>
The ClientOnly requirement
Because IndexeddbPersistence uses browser APIs unavailable on the server, always wrap components that use useProtoDoc (or usePrototype) in <ClientOnly>:
<template>
<ClientOnly>
<ProtoTool :schema="mySchema" />
<template #fallback>
<div class="animate-pulse">Loading…</div>
</template>
</ClientOnly>
</template>
Accessing the raw Y.Doc
const { doc } = useProtoDoc('my-tool')
// Read a value directly
const fieldsMap = doc.getMap('fields')
const mrr = fieldsMap.get('mrr')
// Write a value directly (in a Y.js transaction)
doc.transact(() => {
fieldsMap.set('mrr', 15000)
})
// Observe changes
fieldsMap.observe(event => {
console.log('changed keys:', [...event.keysChanged])
})
Pinia store integration
When using useProtoDoc inside a Pinia store, never call onUnmounted inside the store — it will register to the initializing component and break when that component unmounts. Use the default ref-counting cleanup instead:
// ❌ Wrong — causes subtle lifecycle bugs
defineStore('myStore', () => {
onUnmounted(() => { /* cleanup */ }) // DON'T do this inside a Pinia store
})
// ✅ Correct — let useProtoDoc's ref counting handle cleanup
defineStore('myStore', () => {
const { doc, isReady } = useProtoDoc('my-tool', { skipAutoCleanup: true })
return { doc, isReady }
})
Pass skipAutoCleanup: true when the store owns the document lifetime (i.e. the store is a Pinia singleton that outlives any single component).
usePrototype
The high-level facade composable that combines document management, field sync, CRUD, derived computation, and cross-tool outputs into a single call.
useProtoCollection
High-level CRUD composable for Y.Array-backed collection lists — reactive items, add/update/remove/move, search, sort, and item count.