Skip to content

useNode

import { useNode } from '@xnet/react'
import { useEditor, EditorContent } from '@tiptap/react'
import Collaboration from '@tiptap/extension-collaboration'
import { PageSchema } from './schema'
function PageEditor({ pageId }: { pageId: string }) {
const { data, doc, update, syncStatus, remoteUsers } = useNode(PageSchema, pageId, {
createIfMissing: { title: 'Untitled' },
did: currentUserDID
})
const editor = useEditor(
{
extensions: [
// ... your extensions
Collaboration.configure({ document: doc })
]
},
[doc]
)
if (!data) return <p>Loading...</p>
return (
<div>
<input value={data.title} onChange={(e) => update({ title: e.target.value })} />
<div>{syncStatus === 'connected' ? `${remoteUsers.length} collaborator(s)` : syncStatus}</div>
<EditorContent editor={editor} />
</div>
)
}
function useNode<P extends Record<string, PropertyBuilder>>(
schema: DefinedSchema<P>,
id: string | null,
options?: UseNodeOptions<P>
): UseNodeResult<P>

Passing null for id disables the hook — data and doc will be null.

ParameterTypeDescription
schemaDefinedSchema<P>The schema for this node. Must have document: 'yjs' for rich text.
idstring | nullThe node ID to load. null disables the hook.
optionsUseNodeOptions<P>Configuration for sync, persistence, and auto-creation.
OptionTypeDefaultDescription
signalingServersstring[]['ws://localhost:4444']WebSocket servers for peer discovery.
disableSyncbooleanfalseDisable P2P sync (local-only mode).
persistDebouncenumber1000Milliseconds to debounce Y.Doc saves to storage.
createIfMissingInferCreateProps<P>Auto-create the node if it doesn’t exist.
didstringUser’s DID for presence awareness (cursors, user list).
FieldTypeDescription
dataFlatNode<P> | nullThe node’s structured properties (flattened). null if loading or not found.
docY.Doc | nullYjs document for collaborative editing. null if schema has no document: 'yjs'.
FieldTypeDescription
update(props: Partial<InferCreateProps<P>>) => Promise<void>Update structured properties. Dual-writes to both NodeStore and Y.Doc meta map.
remove() => Promise<void>Soft-delete this node.
FieldTypeDescription
loadingbooleantrue during initial load.
errorError | nullAny error during loading, sync, or persistence.
isDirtybooleantrue if the Y.Doc has unsaved changes (debounce timer is pending).
lastSavedAtnumber | nullTimestamp of last successful persistence.
wasCreatedbooleantrue if the node was auto-created via createIfMissing.
FieldTypeDescription
syncStatusSyncStatus'offline' | 'connecting' | 'connected' | 'error'
syncErrorstring | nullHuman-readable error message when syncStatus is 'error'.
peerCountnumberNumber of currently connected peers editing this document.
FieldTypeDescription
remoteUsersRemoteUser[]Other users viewing this document. Each has clientId, did, and color.
awarenessAwareness | nullYjs Awareness instance. Pass to TipTap’s CollaborationCursor extension.
FieldTypeDescription
save() => Promise<void>Manually flush pending changes (bypass debounce timer).
reload() => Promise<void>Reload node and document from storage.

If your schema doesn’t have document: 'yjs', doc will be null and useNode behaves like a single-node version of useQuery + useMutate:

const { data, update } = useNode(TaskSchema, taskId)
// Update properties
await update({ status: 'done', title: 'Shipped!' })
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
const { doc, awareness, remoteUsers } = useNode(PageSchema, id, { did })
const editor = useEditor(
{
extensions: [
StarterKit.configure({ history: false }), // Disable built-in history
Collaboration.configure({ document: doc }),
CollaborationCursor.configure({
provider: awareness,
user: { name: 'You', color: '#6366f1' }
})
]
},
[doc]
)

Pass did to enable presence. Remote users are reported with a deterministic color derived from their DID:

const { remoteUsers, syncStatus } = useNode(PageSchema, id, {
did: currentUserDID
})
return (
<div>
{remoteUsers.map((user) => (
<span key={user.clientId} style={{ color: user.color }}>
{user.did.slice(8, 16)}...
</span>
))}
<span>{syncStatus}</span>
</div>
)

For “new document” flows, pass default properties. If the node doesn’t exist, it’s created automatically:

const { data, wasCreated } = useNode(PageSchema, newPageId, {
createIfMissing: { title: 'Untitled', content: '' }
})
if (wasCreated) {
// Focus the title input, show onboarding, etc.
}

If the node exists but is soft-deleted, createIfMissing restores it instead of creating a duplicate.

The debounced persistence timer (default: 1000ms) means there’s a window where changes exist in memory but not on disk. For critical moments, flush manually:

const { save, isDirty } = useNode(PageSchema, id)
const handleNavigateAway = async () => {
if (isDirty) await save()
router.push('/home')
}

This is the most important thing to understand about useNode:

When you call update({ title: 'New Title' }), it writes to two places:

  1. NodeStore — structured data with Lamport timestamps (for useQuery and persistence)
  2. Y.Doc meta map — a Y.Map('meta') inside the Yjs document (for P2P sync)

When a remote peer changes the Y.Doc meta map, a metaObserver picks up the change and writes it back to the NodeStore. This bidirectional bridge keeps both stores in sync.

  1. Mount — NodeStore lookup. If not found and createIfMissing is set, create the node.
  2. Init Y.Doc — If schema has document: 'yjs', load stored content and initialize the Yjs document.
  3. Connect — Acquire a sync connection from SyncManager (or create a WebSocketSyncProvider as fallback).
  4. Editing — User and peer changes flow through Yjs CRDT. Property changes go through the dual-write bridge.
  5. Persist — Y.Doc changes are debounced and saved to IndexedDB as Y.encodeStateAsUpdate().
  6. Unmount — Flush all pending changes immediately, release the sync connection.