docs/references/data/data-api-in-renderer.md
This guide covers how to use the DataApi system in React components and the renderer process.
Fetch data with automatic caching and revalidation via SWR.
import { useQuery } from '@data/hooks/useDataApi'
// Basic usage
const { data, isLoading, error } = useQuery('/topics')
// With query parameters
const { data: messages } = useQuery('/messages', {
query: { topicId: 'abc123', page: 1, limit: 20 }
})
// With path parameters (inferred from path)
const { data: topic } = useQuery('/topics/abc123')
// Conditional fetching
const { data } = useQuery('/topics', { enabled: !!topicId })
// With refresh callback
const { data, mutate, refetch } = useQuery('/topics')
// Refresh data
refetch() // or await mutate()
Perform data modifications with loading states.
import { useMutation } from '@data/hooks/useDataApi'
// Create (POST)
const { trigger: createTopic, isLoading } = useMutation('POST', '/topics')
const newTopic = await createTopic({ body: { name: 'New Topic' } })
// Update (PUT - full replacement)
const { trigger: replaceTopic } = useMutation('PUT', '/topics/abc123')
await replaceTopic({ body: { name: 'Updated Name', description: '...' } })
// Partial Update (PATCH)
const { trigger: updateTopic } = useMutation('PATCH', '/topics/abc123')
await updateTopic({ body: { name: 'New Name' } })
// Delete
const { trigger: deleteTopic } = useMutation('DELETE', '/topics/abc123')
await deleteTopic()
// With auto-refresh of other queries
const { trigger } = useMutation('POST', '/topics', {
refresh: ['/topics'], // Refresh these keys on success
onSuccess: (data) => logger.info('Created:', data)
})
Functions returned by data hooks (trigger, invalidate, refetch, nextPage,
prevPage, reset, ...) keep stable identity across re-renders, consistent with
SWR's own mutate/trigger, changing only when a meaningful input changes
(e.g. nextPage when page availability flips). useMutation's trigger reads
its options through a ref, so inline options objects never churn its identity.
For infinite scroll UIs with "Load More" pattern. The hook exposes pages —
the raw response array — and consumers derive a flat item list with
useInfiniteFlatItems, picking the order that matches the endpoint and
container layout.
import { useInfiniteQuery, useInfiniteFlatItems } from '@data/hooks/useDataApi'
// Simple feed: page 0 newest, within-page descending — page order matches display order
const { pages, hasNext, loadNext, isLoading } = useInfiniteQuery('/feed')
const items = useInfiniteFlatItems(pages)
// Branch-walk in `column-reverse` chat container: page 0 newest, within-page
// ascending. `reverseItems: true` flips each page so the flat output is
// newest-first and feeds straight into the reversed layout.
const { pages, hasNext, loadNext } = useInfiniteQuery('/topics/:topicId/messages', {
params: { topicId }
})
const messages = useInfiniteFlatItems(pages, { reverseItems: true })
const activeNodeId = pages[0]?.activeNodeId ?? null // top-level metadata, no cast
// Time-ascending render in non-`column-reverse` container: flip page order
const items = useInfiniteFlatItems(pages, { reversePages: true })
useInfiniteQuery rejects offset-paginated paths at compile time — the path
generic is constrained via CursorPaginatedPath. pages is reference-stable
across rerenders when SWR's underlying data is unchanged, so
useInfiniteFlatItems(pages) skips recomputation.
For page-by-page navigation with previous/next controls. Rejects cursor-paginated paths at compile time.
import { usePaginatedQuery } from '@data/hooks/useDataApi'
const { items, page, total, hasNext, hasPrev, nextPage, prevPage } =
usePaginatedQuery('/topics', { limit: 10 })
// items: current page items
// page/total: current page number and total count
// nextPage()/prevPage(): navigate between pages
| Use Case | Hook |
|---|---|
| Infinite scroll, chat, feeds | useInfiniteQuery |
| Page navigation, tables | usePaginatedQuery |
| Manual control | useQuery |
Each pagination hook constrains its path generic to the matching pagination
shape: passing a cursor path to usePaginatedQuery or an offset path to
useInfiniteQuery is a compile-time error, not a silent runtime hang.
For the full pagination model — when to choose offset vs cursor, the wire contract, and the server-side implementation — see the Pagination Guide.
Hooks accept either a concrete path (id already inlined, e.g. /providers/abc123) or a template path with :placeholders and a separate params option.
// Concrete path — use when the id is stable in the caller (props, hook arg, etc.)
const { data } = useQuery(`/providers/${providerId}`)
const { data } = useQuery(providerPath(providerId)) // helper producing the same string
// Template path — use when one hook instance operates on different ids over time
// (sidebar list, command palette, URL handler, row-level actions in a loop)
const { data } = useQuery('/providers/:providerId', { params: { providerId } })
const { trigger } = useMutation('DELETE', '/providers/:providerId/api-keys/:keyId', {
refresh: ({ args }) => [
`/providers/${args.params.providerId}`,
`/providers/${args.params.providerId}/api-keys`
]
})
await trigger({ params: { providerId, keyId } })
Both forms produce byte-for-byte identical SWR cache keys, so reading with one form and refreshing with the other stays consistent.
| Scenario | Form |
|---|---|
<ProviderSettings providerId={id}> (stable id from props) | Concrete path |
| Sidebar "Delete any provider" action | Template path |
| Command palette / URL handler operating on arbitrary ids | Template path |
Row action inside .map() — one hook per row | Concrete path |
useMutationuseSWRMutation keys its isMutating/error state by path. A single template-path useMutation instance therefore shares loading state across all params. Triggering different ids concurrently from one hook instance will mix their states:
// ❌ BAD: isMutating is shared; the second trigger clobbers the first
const { trigger, isLoading } = useMutation('DELETE', '/providers/:providerId')
await Promise.all([
trigger({ params: { providerId: 'a' } }),
trigger({ params: { providerId: 'b' } })
])
// ✅ Better: mount one hook per row with a concrete path
function ProviderRow({ id }) {
const { trigger, isLoading } = useMutation('DELETE', providerPath(id))
return <button onClick={() => trigger()} disabled={isLoading}>Delete</button>
}
In dev mode, a concurrent trigger with changed params logs a warning.
refresh declares which SWR cache keys to invalidate after a successful mutation. Three forms are supported; pick the most precise one that fits.
useMutation('POST', '/topics', { refresh: ['/topics'] })
Invalidates only ['/topics']. Use when you know exactly which paths are affected and they don't depend on mutation input or output.
/* suffix (prefix match)// Invalidates /providers, /providers/abc, /providers/abc/api-keys, /providers/abc/api-keys/k1, ...
useMutation('DELETE', '/providers/:providerId', {
refresh: ({ args }) => ['/providers', `/providers/${args.params.providerId}/*`]
})
The trailing slash in the prefix (kept automatically) prevents false positives on siblings like /providers-archived.
/* is uniquely useful for invalidating sub-path instances whose ids the mutation doesn't know, for example useQuery('/providers/abc/api-keys/keyId-001') entries subscribed elsewhere in the tree. A function-form enumeration can't name these keys.
// Invalidation keys depend on trigger args
useMutation('DELETE', '/messages/:messageId', {
refresh: ({ args }) => [`/topics/${args.body.topicId}/tree`]
})
// Invalidation keys depend on server response
useMutation('POST', '/messages', {
refresh: ({ result }) => [`/topics/${result.topicId}/messages`, `/messages/${result.parentId}`]
})
Use when the set of keys is only known at call time (ids from args/result).
| Need | Form |
|---|---|
| Static, known keys | Array |
| Invalidate all sub-paths of a resource | /* prefix in array |
| Invalidate keys computed from args / result | Function |
| Both: fan-out + precision | Function returning a mix of exact and /* |
/* as a full-cache reset. ['/*'] or short prefixes like ['/m*'] throw in dev. Always write a complete path segment./* against high-cardinality lists (e.g., /messages/*). It revalidates every message-scoped query across all open windows. Use function form with a specific parent id instead (/topics/${id}/messages).refresh is for DataApi keys only. Non-SQLite data (Cache, Preference) has its own invalidation mechanisms.refresh only covers this window's own mutations. For writes from other windows or main-process background paths, main broadcasts a DataApiDataChangeEffect[] after each committed write; consumers opt in per endpoint and decide their own convergence (revalidate / rebuild / ignore).
import { useDataChange } from '@data/hooks/useDataApi'
// Conservative list convergence: any signal → refetch
const { refetch } = useQuery('/topics')
useDataChange('/topics', () => refetch())
// Multiple endpoints, one merged callback per notification
useDataChange(['/topics', '/topics/latest'], () => refreshAll())
// By-ID surface: filter with entityIds (absent = no claim → act)
useDataChange('/topics/:id', (effects) => {
if (effects.some((e) => !e.entityIds || e.entityIds.includes(myId))) mutate()
})
// Non-React code: same facility on the service (returns unsubscribe)
const unsubscribe = dataApiService.onDataChanged('/topics', (effects) => { ... })
Semantics (frozen by the Phase A contract, see issue 17144):
endpoint + optional kind (projection / membership / order) + dimension + entityIds.dimension/entityIds means "no claim — assume relevant", never "no impact".For non-React code or more control.
import { dataApiService } from '@data/DataApiService'
// GET request
const topics = await dataApiService.get('/topics')
const topic = await dataApiService.get('/topics/abc123')
const messages = await dataApiService.get('/topics/abc123/messages', {
query: { page: 1, limit: 20 }
})
// POST request
const newTopic = await dataApiService.post('/topics', {
body: { name: 'New Topic' }
})
// PUT request (full replacement)
const updatedTopic = await dataApiService.put('/topics/abc123', {
body: { name: 'Updated', description: 'Full update' }
})
// PATCH request (partial update)
const patchedTopic = await dataApiService.patch('/topics/abc123', {
body: { name: 'Just update name' }
})
// DELETE request
await dataApiService.delete('/topics/abc123')
function TopicList() {
const { data, isLoading, error } = useQuery('/topics')
if (isLoading) return <Loading />
if (error) {
if (error.code === ErrorCode.NOT_FOUND) {
return <NotFound />
}
return <Error message={error.message} />
}
return <List items={data} />
}
import { DataApiError, ErrorCode } from '@shared/data/api/errors'
try {
await dataApiService.post('/topics', { body: data })
} catch (error) {
if (error instanceof DataApiError) {
switch (error.code) {
case ErrorCode.VALIDATION_ERROR:
// Handle validation errors
const fieldErrors = error.details?.fieldErrors
break
case ErrorCode.NOT_FOUND:
// Handle not found
break
case ErrorCode.CONFLICT:
// Handle conflict
break
default:
// Handle other errors
}
}
}
if (error instanceof DataApiError && error.isRetryable) {
// Safe to retry: SERVICE_UNAVAILABLE, TIMEOUT, etc.
await retry(operation)
}
function CreateTopicForm() {
// Use refresh option to auto-refresh /topics after creation
const { trigger: createTopic, isLoading } = useMutation('POST', '/topics', {
refresh: ['/topics']
})
const handleSubmit = async (data: CreateTopicDto) => {
try {
await createTopic({ body: data })
toast.success('Topic created')
} catch (error) {
toast.error('Failed to create topic')
}
}
return (
<form onSubmit={handleSubmit}>
<button disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create'}
</button>
</form>
)
}
function TopicItem({ topic }: { topic: Topic }) {
// Use optimisticData for automatic optimistic updates with rollback
const { trigger: updateTopic } = useMutation('PATCH', `/topics/${topic.id}`, {
optimisticData: { ...topic, starred: !topic.starred }
})
const handleToggleStar = async () => {
try {
await updateTopic({ body: { starred: !topic.starred } })
} catch (error) {
// Rollback happens automatically when optimisticData is set
toast.error('Failed to update')
}
}
return (
<div>
<span>{topic.name}</span>
<button onClick={handleToggleStar}>
{topic.starred ? '★' : '☆'}
</button>
</div>
)
}
function MessageList({ topicId }: { topicId: string }) {
// First query: get topic
const { data: topic } = useQuery(`/topics/${topicId}`)
// Second query: depends on first (only runs when topic exists)
const { data: messages } = useQuery(
topic ? `/topics/${topicId}/messages` : null
)
if (!topic) return <Loading />
return (
<div>
<h1>{topic.name}</h1>
<MessageList messages={messages} />
</div>
)
}
function LiveTopicList() {
const { data } = useQuery('/topics', {
refreshInterval: 5000 // Poll every 5 seconds
})
return <List items={data} />
}
The API is fully typed based on schema definitions:
// Types are inferred from schema
const { data } = useQuery('/topics')
// data is typed as PaginatedResponse<Topic>
const { trigger } = useMutation('POST', '/topics')
// trigger expects { body: CreateTopicDto }
// returns Topic
// Path parameters are type-checked
const { data: topic } = useQuery('/topics/abc123')
// TypeScript knows this returns Topic
useQuery and useMutation handle loading/error statesuseInfiniteQuery for infinite scroll, usePaginatedQuery for page navigationuseInfiniteFlatItems: Pick reversePages / reverseItems to match the endpoint's pagination shape and container layout — never assume page-load order equals item display orderrefresh option to keep the UI in syncenabled: false to skip queries when dependencies aren't readytrigger, invalidate, refetch, etc. directly in useCallback/useEffect dependency arrays — never re-wrap them in refs or omit them from deps to dodge identity churn