Hook Patterns
List + detail pattern
Section titled “List + detail pattern”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 and navigate
Section titled “Create and navigate”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>}Stable filter references
Section titled “Stable filter references”Avoid re-creating filter objects on every render:
// Option 1: Module-level constantconst 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 filtersfunction FilteredTasks({ status }: { status: string }) { const filter = useMemo(() => ({ where: { status } }), [status]) const { data } = useQuery(TaskSchema, filter) // ...}Debounced updates
Section titled “Debounced updates”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} />}Error boundaries
Section titled “Error boundaries”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> )}Conditional hooks
Section titled “Conditional hooks”When you might not have an ID yet:
// useNode accepts null to disablefunction MaybeEditor({ id }: { id: string | null }) { const { data } = useNode(PageSchema, id)
if (!data) return <p>Select a page</p> return <Editor data={data} />}Sync status indicator
Section titled “Sync status indicator”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>}