Fixes #14593 (mutate type collapses to CursorPaginationResponse, erasing subtype fields like BranchMessagesResponse.activeNodeId) and #14594 (default flatMap bakes in a "page-load order == display order" assumption that breaks on branch-walk + column-reverse layouts). Also closes a dual silent-failure where useInfiniteQuery silently accepted offset paths (stuck at page 1) and usePaginatedQuery silently accepted cursor paths (total stays 0). useInfiniteQuery now exposes raw pages: TResponse[] (no items field), preserving subtype fields and typing mutate as SWRInfiniteKeyedMutator correctly. The new useInfiniteFlatItems hook derives the flat list with explicit reversePages / reverseItems switches, so flattening is no longer hidden behind an implicit page-load ordering. Path generics on both hooks are gated via CursorPaginatedPath / OffsetPaginatedPath, built on InferPaginationMode so the optional nextCursor field cannot collapse offset paths into the cursor guard structurally. DEFAULT_SWR_OPTIONS realigned for IPC (not HTTP) semantics: DataApiService is the single retry decision point (shouldRetryOnError: false), reconnect revalidation disabled, keepPreviousData enabled to suppress search/ pagination flicker. loadNext drops its isValidating guard (SWR's dedupingInterval is the right dedup site); usePaginatedQuery reset-on-query-change uses unstable_serialize to be key-order independent. useInfiniteQuery had zero consumers when rewritten — the breaking removal of the items field carries no migration cost. Comprehensive test coverage for type contracts, flat-items behavior, infinite integration, and paginated reset; renderer data docs synced.
14 KiB
DataApi in Renderer
This guide covers how to use the DataApi system in React components and the renderer process.
React Hooks
useQuery (GET Requests)
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()
useMutation (POST/PUT/PATCH/DELETE)
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)
})
useInfiniteQuery (Cursor-based Infinite Scroll)
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.
usePaginatedQuery (Offset-based Pagination)
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
Choosing Pagination Hooks
| 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.
Dynamic Paths
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.
When to use which
| 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 |
Caveat: concurrent trigger on template useMutation
useSWRMutation 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 Patterns
refresh declares which SWR cache keys to invalidate after a successful mutation. Three forms are supported; pick the most precise one that fits.
Static paths (exact match)
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.
Function form (dynamic 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).
Choosing between forms
| 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 /* |
Misuse to avoid
- Don't use
/*as a full-cache reset.['/*']or short prefixes like['/m*']throw in dev. Always write a complete path segment. - Don't reach for function form when a static array is enough. Extra runtime cost and hides intent.
- Don't use
/*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). - Don't mix template paths and helper functions for the same resource in one module. Cache keys end up identical but code review becomes harder. Pick one form per module.
refreshis for DataApi keys only. Non-SQLite data (Cache, Preference) has its own invalidation mechanisms.
DataApiService Direct Usage
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')
Error Handling
With Hooks
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} />
}
With Try-Catch
import { DataApiError, ErrorCode } from '@shared/data/api'
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
}
}
}
Retryable Errors
if (error instanceof DataApiError && error.isRetryable) {
// Safe to retry: SERVICE_UNAVAILABLE, TIMEOUT, etc.
await retry(operation)
}
Common Patterns
Create Form
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}>
{/* form fields */}
<button disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create'}
</button>
</form>
)
}
Optimistic Updates
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>
)
}
Dependent Queries
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>
)
}
Polling for Updates
function LiveTopicList() {
const { data } = useQuery('/topics', {
refreshInterval: 5000 // Poll every 5 seconds
})
return <List items={data} />
}
Type Safety
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
Best Practices
- Use hooks for components:
useQueryanduseMutationhandle loading/error states - Choose the right pagination hook: Use
useInfiniteQueryfor infinite scroll,usePaginatedQueryfor page navigation - Derive flat infinite items via
useInfiniteFlatItems: PickreversePages/reverseItemsto match the endpoint's pagination shape and container layout — never assume page-load order equals item display order - Handle loading states: Always show feedback while data is loading
- Handle errors gracefully: Provide meaningful error messages to users
- Revalidate after mutations: Use
refreshoption to keep the UI in sync - Use conditional fetching: Set
enabled: falseto skip queries when dependencies aren't ready - Batch related operations: Consider using transactions for multiple updates