useProtoDoc

Low-level Y.js document lifecycle composable — creates, caches, and manages Y.Doc instances with IndexedDB persistence, optional server sync, and corruption detection.

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/sync calls are made
  • No POST /api/yjs/snapshots/create calls 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).

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.