useNode
Quick example
Section titled “Quick example”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> )}Signature
Section titled “Signature”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.
Parameters
Section titled “Parameters”| Parameter | Type | Description |
|---|---|---|
schema | DefinedSchema<P> | The schema for this node. Must have document: 'yjs' for rich text. |
id | string | null | The node ID to load. null disables the hook. |
options | UseNodeOptions<P> | Configuration for sync, persistence, and auto-creation. |
UseNodeOptions
Section titled “UseNodeOptions”| Option | Type | Default | Description |
|---|---|---|---|
signalingServers | string[] | ['ws://localhost:4444'] | WebSocket servers for peer discovery. |
disableSync | boolean | false | Disable P2P sync (local-only mode). |
persistDebounce | number | 1000 | Milliseconds to debounce Y.Doc saves to storage. |
createIfMissing | InferCreateProps<P> | — | Auto-create the node if it doesn’t exist. |
did | string | — | User’s DID for presence awareness (cursors, user list). |
Return value
Section titled “Return value”| Field | Type | Description |
|---|---|---|
data | FlatNode<P> | null | The node’s structured properties (flattened). null if loading or not found. |
doc | Y.Doc | null | Yjs document for collaborative editing. null if schema has no document: 'yjs'. |
Mutations
Section titled “Mutations”| Field | Type | Description |
|---|---|---|
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. |
| Field | Type | Description |
|---|---|---|
loading | boolean | true during initial load. |
error | Error | null | Any error during loading, sync, or persistence. |
isDirty | boolean | true if the Y.Doc has unsaved changes (debounce timer is pending). |
lastSavedAt | number | null | Timestamp of last successful persistence. |
wasCreated | boolean | true if the node was auto-created via createIfMissing. |
| Field | Type | Description |
|---|---|---|
syncStatus | SyncStatus | 'offline' | 'connecting' | 'connected' | 'error' |
syncError | string | null | Human-readable error message when syncStatus is 'error'. |
peerCount | number | Number of currently connected peers editing this document. |
Presence
Section titled “Presence”| Field | Type | Description |
|---|---|---|
remoteUsers | RemoteUser[] | Other users viewing this document. Each has clientId, did, and color. |
awareness | Awareness | null | Yjs Awareness instance. Pass to TipTap’s CollaborationCursor extension. |
Actions
Section titled “Actions”| Field | Type | Description |
|---|---|---|
save | () => Promise<void> | Manually flush pending changes (bypass debounce timer). |
reload | () => Promise<void> | Reload node and document from storage. |
Usage patterns
Section titled “Usage patterns”Structured properties only (no rich text)
Section titled “Structured properties only (no rich text)”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 propertiesawait update({ status: 'done', title: 'Shipped!' })Rich text with TipTap
Section titled “Rich text with TipTap”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])Presence awareness
Section titled “Presence awareness”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>)Auto-create with createIfMissing
Section titled “Auto-create with createIfMissing”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.
Manual save
Section titled “Manual save”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')}The dual-write model
Section titled “The dual-write model”This is the most important thing to understand about useNode:
When you call update({ title: 'New Title' }), it writes to two places:
- NodeStore — structured data with Lamport timestamps (for
useQueryand persistence) - 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.
Lifecycle
Section titled “Lifecycle”- Mount — NodeStore lookup. If not found and
createIfMissingis set, create the node. - Init Y.Doc — If schema has
document: 'yjs', load stored content and initialize the Yjs document. - Connect — Acquire a sync connection from
SyncManager(or create aWebSocketSyncProvideras fallback). - Editing — User and peer changes flow through Yjs CRDT. Property changes go through the dual-write bridge.
- Persist — Y.Doc changes are debounced and saved to IndexedDB as
Y.encodeStateAsUpdate(). - Unmount — Flush all pending changes immediately, release the sync connection.
Gotchas
Section titled “Gotchas”Related
Section titled “Related”useQuery— for reading multiple nodes (no Yjs)useMutate— for mutations without a Yjs documentuseIdentity— get the current DID for presence- Collaboration guide — full walkthrough of building a collaborative editor