Skip to content

Hook Patterns

The most common pattern: a list view with a detail editor.

function App() {
const [selectedId, setSelectedId] = useState<string | null>(null)
return (
<div style={{ display: 'flex' }}>
<TaskList onSelect={setSelectedId} />
{selectedId && <TaskDetail id={selectedId} />}
</div>
)
}
function TaskList({ onSelect }: { onSelect: (id: string) => void }) {
const { data: tasks } = useQuery(TaskSchema, {
orderBy: { createdAt: 'desc' }
})
return (
<ul>
{tasks.map((task) => (
<li key={task.id} onClick={() => onSelect(task.id)}>
{task.title}
</li>
))}
</ul>
)
}
function TaskDetail({ id }: { id: string }) {
const { data, doc, update } = useNode(TaskSchema, id)
if (!data) return null
return (
<div>
<input value={data.title} onChange={(e) => update({ title: e.target.value })} />
{doc && <RichTextEditor doc={doc} />}
</div>
)
}

Create a node and immediately navigate to it:

function NewPageButton() {
const { create } = useMutate()
const navigate = useNavigate()
const handleCreate = async () => {
const page = await create(PageSchema, { title: 'Untitled' })
if (page) navigate(`/pages/${page.id}`)
}
return <button onClick={handleCreate}>New Page</button>
}

Avoid re-creating filter objects on every render:

// Option 1: Module-level constant
const ACTIVE_FILTER = {
where: { status: 'active' as const },
orderBy: { updatedAt: 'desc' as const }
} satisfies QueryFilter<typeof ProjectSchema._properties>
function ActiveProjects() {
const { data } = useQuery(ProjectSchema, ACTIVE_FILTER)
// ...
}
// Option 2: useMemo for dynamic filters
function FilteredTasks({ status }: { status: string }) {
const filter = useMemo(() => ({ where: { status } }), [status])
const { data } = useQuery(TaskSchema, filter)
// ...
}

For frequently changing values (like text inputs), debounce the update:

function TitleInput({ id }: { id: string }) {
const { data, update } = useNode(TaskSchema, id)
const [local, setLocal] = useState(data?.title ?? '')
const timeoutRef = useRef<number>()
// Sync from remote changes
useEffect(() => {
if (data?.title !== undefined) setLocal(data.title)
}, [data?.title])
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setLocal(value)
clearTimeout(timeoutRef.current)
timeoutRef.current = window.setTimeout(() => {
update({ title: value })
}, 300)
}
return <input value={local} onChange={handleChange} />
}

Wrap xNet-powered components in error boundaries for resilience:

function App() {
return (
<XNetProvider config={config}>
<ErrorBoundary fallback={<p>Something went wrong</p>}>
<TaskList />
</ErrorBoundary>
</XNetProvider>
)
}

When you might not have an ID yet:

// useNode accepts null to disable
function MaybeEditor({ id }: { id: string | null }) {
const { data } = useNode(PageSchema, id)
if (!data) return <p>Select a page</p>
return <Editor data={data} />
}

A reusable component showing connection state:

function SyncIndicator({ id }: { id: string }) {
const { syncStatus, peerCount, syncError } = useNode(PageSchema, id)
const statusMap = {
offline: { color: 'gray', label: 'Offline' },
connecting: { color: 'yellow', label: 'Connecting...' },
connected: { color: 'green', label: `${peerCount} peer(s)` },
error: { color: 'red', label: syncError ?? 'Error' }
}
const { color, label } = statusMap[syncStatus]
return <span style={{ color }}>{label}</span>
}