From 6567d9d255388bdd171e31b695ebb587bc36a331 Mon Sep 17 00:00:00 2001
From: fullex <0xfullex@gmail.com>
Date: Mon, 5 Jan 2026 00:12:01 +0800
Subject: [PATCH] feat: update useDataApi hook and documentation for improved
loading states and refetch logic
- Refactored `useQuery` and `useMutation` hooks to replace `loading` with `isLoading` for consistency in naming conventions.
- Enhanced `useQuery` to include `isRefreshing` state for better tracking of background revalidation.
- Updated documentation and examples to reflect changes in hook signatures and loading state management.
- Improved mock implementations in tests to align with the new hook signatures, ensuring accurate testing of loading states.
---
.../references/data/data-api-in-renderer.md | 132 ++--
src/renderer/src/data/hooks/useDataApi.ts | 602 +++++++++++-------
tests/__mocks__/renderer/useDataApi.ts | 98 +--
3 files changed, 518 insertions(+), 314 deletions(-)
diff --git a/docs/en/references/data/data-api-in-renderer.md b/docs/en/references/data/data-api-in-renderer.md
index abe79ca8c1..8d4b9f07f6 100644
--- a/docs/en/references/data/data-api-in-renderer.md
+++ b/docs/en/references/data/data-api-in-renderer.md
@@ -12,7 +12,7 @@ Fetch data with automatic caching and revalidation via SWR.
import { useQuery } from '@data/hooks/useDataApi'
// Basic usage
-const { data, loading, error } = useQuery('/topics')
+const { data, isLoading, error } = useQuery('/topics')
// With query parameters
const { data: messages } = useQuery('/messages', {
@@ -23,12 +23,12 @@ const { data: messages } = useQuery('/messages', {
const { data: topic } = useQuery('/topics/abc123')
// Conditional fetching
-const { data } = useQuery(topicId ? `/topics/${topicId}` : null)
+const { data } = useQuery('/topics', { enabled: !!topicId })
// With refresh callback
-const { data, mutate } = useQuery('/topics')
+const { data, mutate, refetch } = useQuery('/topics')
// Refresh data
-await mutate()
+refetch() // or await mutate()
```
### useMutation (POST/PUT/PATCH/DELETE)
@@ -39,22 +39,68 @@ Perform data modifications with loading states.
import { useMutation } from '@data/hooks/useDataApi'
// Create (POST)
-const { trigger: createTopic, isMutating } = useMutation('/topics', 'POST')
+const { trigger: createTopic, isLoading } = useMutation('POST', '/topics')
const newTopic = await createTopic({ body: { name: 'New Topic' } })
// Update (PUT - full replacement)
-const { trigger: replaceTopic } = useMutation('/topics/abc123', 'PUT')
+const { trigger: replaceTopic } = useMutation('PUT', '/topics/abc123')
await replaceTopic({ body: { name: 'Updated Name', description: '...' } })
// Partial Update (PATCH)
-const { trigger: updateTopic } = useMutation('/topics/abc123', 'PATCH')
+const { trigger: updateTopic } = useMutation('PATCH', '/topics/abc123')
await updateTopic({ body: { name: 'New Name' } })
// Delete
-const { trigger: deleteTopic } = useMutation('/topics/abc123', '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) => console.log('Created:', data)
+})
```
+### useInfiniteQuery (Cursor-based Infinite Scroll)
+
+For infinite scroll UIs with "Load More" pattern.
+
+```typescript
+import { useInfiniteQuery } from '@data/hooks/useDataApi'
+
+const { items, isLoading, hasNext, loadNext } = useInfiniteQuery('/messages', {
+ query: { topicId: 'abc123' },
+ limit: 20
+})
+
+// items: all loaded items flattened
+// loadNext(): load next page
+// hasNext: true if more pages available
+```
+
+### usePaginatedQuery (Offset-based Pagination)
+
+For page-by-page navigation with previous/next controls.
+
+```typescript
+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` |
+
## DataApiService Direct Usage
For non-React code or more control.
@@ -94,9 +140,9 @@ await dataApiService.delete('/topics/abc123')
```typescript
function TopicList() {
- const { data, loading, error } = useQuery('/topics')
+ const { data, isLoading, error } = useQuery('/topics')
- if (loading) return
+ if (isLoading) return
if (error) {
if (error.code === ErrorCode.NOT_FOUND) {
return
@@ -146,39 +192,18 @@ if (error instanceof DataApiError && error.isRetryable) {
## Common Patterns
-### List with Pagination
-
-```typescript
-function TopicListWithPagination() {
- const [page, setPage] = useState(1)
- const { data, loading } = useQuery('/topics', {
- query: { page, limit: 20 }
- })
-
- return (
- <>
-
-
- >
- )
-}
-```
-
### Create Form
```typescript
function CreateTopicForm() {
- const { trigger: createTopic, isMutating } = useMutation('/topics', 'POST')
- const { mutate } = useQuery('/topics') // For revalidation
+ // 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 })
- await mutate() // Refresh list
toast.success('Topic created')
} catch (error) {
toast.error('Failed to create topic')
@@ -188,8 +213,8 @@ function CreateTopicForm() {
return (
)
@@ -200,26 +225,16 @@ function CreateTopicForm() {
```typescript
function TopicItem({ topic }: { topic: Topic }) {
- const { trigger: updateTopic } = useMutation(`/topics/${topic.id}`, 'PATCH')
- const { mutate } = useQuery('/topics')
+ // Use optimisticData for automatic optimistic updates with rollback
+ const { trigger: updateTopic } = useMutation('PATCH', `/topics/${topic.id}`, {
+ optimisticData: { ...topic, starred: !topic.starred }
+ })
const handleToggleStar = async () => {
- // Optimistically update the cache
- await mutate(
- current => ({
- ...current,
- items: current.items.map(t =>
- t.id === topic.id ? { ...t, starred: !t.starred } : t
- )
- }),
- { revalidate: false }
- )
-
try {
await updateTopic({ body: { starred: !topic.starred } })
} catch (error) {
- // Revert on failure
- await mutate()
+ // Rollback happens automatically when optimisticData is set
toast.error('Failed to update')
}
}
@@ -279,7 +294,7 @@ The API is fully typed based on schema definitions:
const { data } = useQuery('/topics')
// data is typed as PaginatedResponse
-const { trigger } = useMutation('/topics', 'POST')
+const { trigger } = useMutation('POST', '/topics')
// trigger expects { body: CreateTopicDto }
// returns Topic
@@ -291,8 +306,9 @@ const { data: topic } = useQuery('/topics/abc123')
## Best Practices
1. **Use hooks for components**: `useQuery` and `useMutation` handle loading/error states
-2. **Handle loading states**: Always show feedback while data is loading
-3. **Handle errors gracefully**: Provide meaningful error messages to users
-4. **Revalidate after mutations**: Keep the UI in sync with the database
-5. **Use conditional fetching**: Pass `null` to skip queries when dependencies aren't ready
-6. **Batch related operations**: Consider using transactions for multiple updates
+2. **Choose the right pagination hook**: Use `useInfiniteQuery` for infinite scroll, `usePaginatedQuery` for page navigation
+3. **Handle loading states**: Always show feedback while data is loading
+4. **Handle errors gracefully**: Provide meaningful error messages to users
+5. **Revalidate after mutations**: Use `refresh` option to keep the UI in sync
+6. **Use conditional fetching**: Set `enabled: false` to skip queries when dependencies aren't ready
+7. **Batch related operations**: Consider using transactions for multiple updates
diff --git a/src/renderer/src/data/hooks/useDataApi.ts b/src/renderer/src/data/hooks/useDataApi.ts
index 4af746d9cf..41aa80a016 100644
--- a/src/renderer/src/data/hooks/useDataApi.ts
+++ b/src/renderer/src/data/hooks/useDataApi.ts
@@ -1,17 +1,62 @@
+/**
+ * @fileoverview React hooks for data fetching with SWR integration.
+ *
+ * This module provides type-safe hooks for interacting with the DataApi:
+ *
+ * - {@link useQuery} - Fetch data with automatic caching and revalidation
+ * - {@link useMutation} - Perform POST/PUT/PATCH/DELETE operations
+ * - {@link useInfiniteQuery} - Cursor-based infinite scrolling
+ * - {@link usePaginatedQuery} - Offset-based pagination with navigation
+ * - {@link useInvalidateCache} - Manual cache invalidation
+ * - {@link prefetch} - Warm up cache before user interactions
+ *
+ * All hooks use SWR under the hood for caching, deduplication, and revalidation.
+ *
+ * @example
+ * // Basic data fetching
+ * const { data, isLoading } = useQuery('/topics')
+ *
+ * @example
+ * // Create with auto-refresh
+ * const { trigger } = useMutation('POST', '/topics', { refresh: ['/topics'] })
+ * await trigger({ body: { name: 'New Topic' } })
+ *
+ * @see {@link https://swr.vercel.app SWR Documentation}
+ */
+
+import { dataApiService } from '@data/DataApiService'
import type { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/data/api/apiPaths'
import type { ConcreteApiPaths } from '@shared/data/api/apiTypes'
import {
- isCursorPaginationResponse,
+ type CursorPaginationResponse,
type OffsetPaginationResponse,
type PaginationResponse
} from '@shared/data/api/apiTypes'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import type { KeyedMutator } from 'swr'
-import useSWR, { useSWRConfig } from 'swr'
+import type { KeyedMutator, SWRConfiguration } from 'swr'
+import useSWR, { preload, useSWRConfig } from 'swr'
+import type { SWRInfiniteConfiguration } from 'swr/infinite'
import useSWRInfinite from 'swr/infinite'
+import type { SWRMutationConfiguration } from 'swr/mutation'
import useSWRMutation from 'swr/mutation'
-import { dataApiService } from '../DataApiService'
+/**
+ * Default SWR configuration shared across all hooks.
+ *
+ * @remarks
+ * - `revalidateOnFocus: false` - Prevents refetch when window regains focus
+ * - `revalidateOnReconnect: true` - Refetch when network reconnects
+ * - `dedupingInterval: 5000` - Dedupe requests within 5 seconds
+ * - `errorRetryCount: 3` - Retry failed requests up to 3 times
+ * - `errorRetryInterval: 1000` - Wait 1 second between retries
+ */
+const DEFAULT_SWR_OPTIONS = {
+ revalidateOnFocus: false,
+ revalidateOnReconnect: true,
+ dedupingInterval: 5000,
+ errorRetryCount: 3,
+ errorRetryInterval: 1000
+} as const
// ============================================================================
// Hook Result Types
@@ -24,7 +69,15 @@ type InferPaginatedItem = ResponseForPath {
data?: ResponseForPath
isLoading: boolean
@@ -34,12 +87,17 @@ export interface UseQueryResult {
mutate: KeyedMutator>
}
-/** useMutation result type */
+/**
+ * useMutation result type
+ * @property trigger - Execute the mutation with optional body and query params
+ * @property isLoading - True while the mutation is in progress
+ * @property error - Error object if the last mutation failed
+ */
export interface UseMutationResult<
TPath extends ConcreteApiPaths,
TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'
> {
- mutate: (data?: {
+ trigger: (data?: {
body?: BodyForPath
query?: QueryParamsForPath
}) => Promise>
@@ -47,24 +105,45 @@ export interface UseMutationResult<
error: Error | undefined
}
-/** useInfiniteQuery result type */
+/**
+ * useInfiniteQuery result type (cursor-based pagination)
+ * @property items - All loaded items flattened from all pages
+ * @property isLoading - True during initial load
+ * @property isRefreshing - True during background revalidation
+ * @property error - Error object if the request failed
+ * @property hasNext - True if more pages are available (nextCursor exists)
+ * @property loadNext - Load the next page of items
+ * @property refresh - Revalidate all loaded pages from the server
+ * @property reset - Reset to first page only
+ * @property mutate - SWR mutator for advanced cache control
+ */
export interface UseInfiniteQueryResult {
items: T[]
- pages: PaginationResponse[]
- total: number
- size: number
isLoading: boolean
isRefreshing: boolean
error?: Error
hasNext: boolean
loadNext: () => void
- setSize: (size: number | ((size: number) => number)) => void
refresh: () => void
reset: () => void
- mutate: KeyedMutator[]>
+ mutate: KeyedMutator[]>
}
-/** usePaginatedQuery result type */
+/**
+ * usePaginatedQuery result type (offset-based pagination)
+ * @property items - Items on the current page
+ * @property total - Total number of items across all pages
+ * @property page - Current page number (1-indexed)
+ * @property isLoading - True during initial load
+ * @property isRefreshing - True during background revalidation
+ * @property error - Error object if the request failed
+ * @property hasNext - True if next page exists
+ * @property hasPrev - True if previous page exists (page > 1)
+ * @property prevPage - Navigate to previous page
+ * @property nextPage - Navigate to next page
+ * @property refresh - Revalidate current page from the server
+ * @property reset - Reset to page 1
+ */
export interface UsePaginatedQueryResult {
items: T[]
total: number
@@ -80,94 +159,50 @@ export interface UsePaginatedQueryResult {
reset: () => void
}
-// ============================================================================
-// Utilities
-// ============================================================================
-
/**
- * Unified API fetcher with type-safe method dispatching
- */
-function createApiFetcher(
- method: TMethod
-) {
- return async (
- path: TPath,
- options?: {
- body?: BodyForPath
- query?: Record
- }
- ): Promise> => {
- switch (method) {
- case 'GET':
- return dataApiService.get(path, { query: options?.query })
- case 'POST':
- return dataApiService.post(path, { body: options?.body, query: options?.query })
- case 'PUT':
- return dataApiService.put(path, { body: options?.body || {}, query: options?.query })
- case 'DELETE':
- return dataApiService.delete(path, { query: options?.query })
- case 'PATCH':
- return dataApiService.patch(path, { body: options?.body, query: options?.query })
- default:
- throw new Error(`Unsupported method: ${method}`)
- }
- }
-}
-
-/**
- * Build SWR cache key from path and query
- */
-function buildSWRKey(
- path: TPath,
- query?: Record
-): [TPath, Record?] | null {
- if (query && Object.keys(query).length > 0) {
- return [path, query]
- }
-
- return [path]
-}
-
-/**
- * GET request fetcher for SWR
- */
-function getFetcher([path, query]: [TPath, Record?]): Promise<
- ResponseForPath
-> {
- const apiFetcher = createApiFetcher('GET')
- return apiFetcher(path, { query })
-}
-
-/**
- * Default SWR configuration options shared across hooks
- */
-const DEFAULT_SWR_OPTIONS = {
- revalidateOnFocus: false,
- revalidateOnReconnect: true,
- dedupingInterval: 5000,
- errorRetryCount: 3,
- errorRetryInterval: 1000
-} as const
-
-/**
- * Data fetching hook with SWR caching and revalidation
+ * Data fetching hook with SWR caching and revalidation.
+ *
+ * Features:
+ * - Automatic caching and deduplication
+ * - Background revalidation on focus/reconnect
+ * - Error retry with exponential backoff
+ *
+ * @param path - API endpoint path (e.g., '/topics', '/messages')
+ * @param options - Query options
+ * @param options.query - Query parameters for filtering, pagination, etc.
+ * @param options.enabled - Set to false to disable the request (default: true)
+ * @param options.swrOptions - Override default SWR configuration
+ * @returns Query result with data, loading states, and cache controls
*
* @example
- * const { data, isLoading, error } = useQuery('/items', { query: { page: 1 } })
+ * // Basic usage
+ * const { data, isLoading, error } = useQuery('/topics')
+ *
+ * @example
+ * // With query parameters
+ * const { data } = useQuery('/messages', { query: { topicId: 'abc', limit: 20 } })
+ *
+ * @example
+ * // Conditional fetching
+ * const { data } = useQuery('/topics', { enabled: !!userId })
+ *
+ * @example
+ * // Manual cache update
+ * const { data, mutate } = useQuery('/topics')
+ * mutate({ ...data, name: 'Updated' }, { revalidate: false })
*/
export function useQuery(
path: TPath,
options?: {
/** Query parameters for filtering, pagination, etc. */
query?: QueryParamsForPath
- /** Disable the request */
+ /** Disable the request (default: true) */
enabled?: boolean
- /** Custom SWR options */
- swrOptions?: Parameters[2]
+ /** Override default SWR configuration */
+ swrOptions?: SWRConfiguration
}
): UseQueryResult {
- // Internal type conversion for SWR compatibility
- const key = options?.enabled !== false ? buildSWRKey(path, options?.query as Record) : null
+ const key = options?.enabled !== false ? buildSWRKey(path, options?.query) : null
const { data, error, isLoading, isValidating, mutate } = useSWR(key, getFetcher, {
...DEFAULT_SWR_OPTIONS,
@@ -187,29 +222,59 @@ export function useQuery(
}
/**
- * Mutation hook for POST, PUT, DELETE, PATCH operations
+ * Mutation hook for POST, PUT, DELETE, PATCH operations.
+ *
+ * Features:
+ * - Automatic cache invalidation via refresh option
+ * - Optimistic updates with automatic rollback on error
+ * - Success/error callbacks
+ *
+ * @param method - HTTP method ('POST' | 'PUT' | 'DELETE' | 'PATCH')
+ * @param path - API endpoint path
+ * @param options - Mutation options
+ * @param options.onSuccess - Callback when mutation succeeds
+ * @param options.onError - Callback when mutation fails
+ * @param options.refresh - API paths to revalidate on success
+ * @param options.optimisticData - If provided, updates cache immediately before request completes
+ * @param options.swrOptions - Override SWR mutation configuration
+ * @returns Mutation result with trigger function and loading state
*
* @example
- * const { mutate, isLoading } = useMutation('POST', '/items', {
- * onSuccess: (data) => console.log(data),
- * revalidate: ['/items']
+ * // Basic POST
+ * const { trigger, isLoading } = useMutation('POST', '/topics')
+ * await trigger({ body: { name: 'New Topic' } })
+ *
+ * @example
+ * // With auto-refresh and callbacks
+ * const { trigger } = useMutation('POST', '/topics', {
+ * refresh: ['/topics'],
+ * onSuccess: (data) => toast.success('Created!'),
+ * onError: (error) => toast.error(error.message)
+ * })
+ *
+ * @example
+ * // Optimistic update (UI updates immediately, rolls back on error)
+ * const { trigger } = useMutation('PATCH', '/topics/abc', {
+ * optimisticData: { ...topic, starred: true }
* })
- * await mutate({ body: { title: 'New Item' } })
*/
export function useMutation(
method: TMethod,
path: TPath,
options?: {
- /** Called when mutation succeeds */
+ /** Callback when mutation succeeds */
onSuccess?: (data: ResponseForPath) => void
- /** Called when mutation fails */
+ /** Callback when mutation fails */
onError?: (error: Error) => void
- /** Automatically revalidate these SWR keys on success */
- revalidate?: boolean | string[]
- /** Enable optimistic updates */
- optimistic?: boolean
- /** Optimistic data to use for updates */
+ /** API paths to revalidate on success */
+ refresh?: ConcreteApiPaths[]
+ /** If provided, updates cache immediately (with auto-rollback on error) */
optimisticData?: ResponseForPath
+ /** Override SWR mutation configuration (fetcher, onSuccess, onError are handled internally) */
+ swrOptions?: Omit<
+ SWRMutationConfiguration, Error>,
+ 'fetcher' | 'onSuccess' | 'onError'
+ >
}
): UseMutationResult {
const { mutate: globalMutate } = useSWRConfig()
@@ -220,7 +285,7 @@ export function useMutation(method)
const fetcher = async (
_key: string,
@@ -229,79 +294,87 @@ export function useMutation
- query?: Record
+ query?: QueryParamsForPath
}
}
): Promise> => {
return apiFetcher(path, { body: arg?.body, query: arg?.query })
}
- const { trigger, isMutating, error } = useSWRMutation(path as string, fetcher, {
+ const {
+ trigger: swrTrigger,
+ isMutating,
+ error
+ } = useSWRMutation(path as string, fetcher, {
populateCache: false,
revalidate: false,
onSuccess: async (data) => {
optionsRef.current?.onSuccess?.(data)
- if (optionsRef.current?.revalidate === true) {
- await globalMutate(() => true)
- } else if (Array.isArray(optionsRef.current?.revalidate)) {
- await Promise.all(optionsRef.current.revalidate.map((key) => globalMutate(key)))
+ // Refresh specified keys on success
+ if (optionsRef.current?.refresh?.length) {
+ await Promise.all(optionsRef.current.refresh.map((key) => globalMutate(key)))
}
},
- onError: (error) => optionsRef.current?.onError?.(error)
+ onError: (error) => optionsRef.current?.onError?.(error),
+ ...options?.swrOptions
})
- const optimisticMutate = async (data?: {
+ const trigger = async (data?: {
body?: BodyForPath
query?: QueryParamsForPath
}): Promise> => {
const opts = optionsRef.current
- if (opts?.optimistic && opts?.optimisticData) {
- await globalMutate(path, opts.optimisticData, false)
+ const hasOptimisticData = opts?.optimisticData !== undefined
+
+ // Apply optimistic update if optimisticData is provided
+ if (hasOptimisticData) {
+ await globalMutate(path, opts!.optimisticData, false)
}
try {
- const convertedData = data ? { body: data.body, query: data.query as Record } : undefined
+ const result = await swrTrigger(data)
- const result = await trigger(convertedData)
-
- if (opts?.optimistic) {
+ // Revalidate after optimistic update completes
+ if (hasOptimisticData) {
await globalMutate(path)
}
return result
} catch (err) {
- if (opts?.optimistic && opts?.optimisticData) {
+ // Rollback optimistic update on error
+ if (hasOptimisticData) {
await globalMutate(path)
}
throw err
}
}
- const normalMutate = async (data?: {
- body?: BodyForPath
- query?: QueryParamsForPath
- }): Promise> => {
- const convertedData = data ? { body: data.body, query: data.query as Record } : undefined
-
- return trigger(convertedData)
- }
-
return {
- mutate: optionsRef.current?.optimistic ? optimisticMutate : normalMutate,
+ trigger,
isLoading: isMutating,
error
}
}
/**
- * Hook to invalidate SWR cache entries
+ * Hook to invalidate SWR cache entries and trigger revalidation.
+ *
+ * Use this to manually clear cached data and force a fresh fetch.
+ *
+ * @returns Invalidate function that accepts keys to invalidate
*
* @example
* const invalidate = useInvalidateCache()
- * await invalidate('/items') // specific key
- * await invalidate(['/a', '/b']) // multiple keys
- * await invalidate(true) // all keys
+ *
+ * // Invalidate specific path
+ * await invalidate('/topics')
+ *
+ * // Invalidate multiple paths
+ * await invalidate(['/topics', '/messages'])
+ *
+ * // Invalidate all cached data
+ * await invalidate(true)
*/
export function useInvalidateCache() {
const { mutate } = useSWRConfig()
@@ -320,10 +393,25 @@ export function useInvalidateCache() {
}
/**
- * Prefetch data for warming up before user interactions
+ * Prefetch data to warm up the cache before user interactions.
+ *
+ * Uses SWR preload to fetch and cache data. Subsequent useQuery calls
+ * with the same path and query will use the cached data immediately.
+ *
+ * @param path - API endpoint path to prefetch
+ * @param options - Prefetch options
+ * @param options.query - Query parameters (must match useQuery call)
+ * @returns Promise resolving to the fetched data
*
* @example
- * prefetch('/items', { query: { page: 1 } })
+ * // Prefetch on hover
+ * onMouseEnter={() => prefetch('/topics/abc')}
+ *
+ * @example
+ * // Prefetch with query params
+ * await prefetch('/messages', { query: { topicId: 'abc', limit: 20 } })
+ * // Later, this will be instant:
+ * const { data } = useQuery('/messages', { query: { topicId: 'abc', limit: 20 } })
*/
export function prefetch(
path: TPath,
@@ -331,8 +419,8 @@ export function prefetch(
query?: QueryParamsForPath
}
): Promise> {
- const apiFetcher = createApiFetcher('GET')
- return apiFetcher(path, { query: options?.query as Record })
+ const key = buildSWRKey(path, options?.query)
+ return preload(key, getFetcher)
}
// ============================================================================
@@ -340,100 +428,94 @@ export function prefetch(
// ============================================================================
/**
- * Infinite scrolling hook with cursor/offset pagination
+ * Infinite scrolling hook with cursor-based pagination.
+ *
+ * Automatically loads pages using cursor tokens. Items from all loaded pages
+ * are flattened into a single array for easy rendering.
+ *
+ * @param path - API endpoint path (must return CursorPaginationResponse)
+ * @param options - Infinite query options
+ * @param options.query - Additional query parameters (cursor/limit are managed internally)
+ * @param options.limit - Items per page (default: 10)
+ * @param options.enabled - Set to false to disable fetching (default: true)
+ * @param options.swrOptions - Override SWR infinite configuration
+ * @returns Infinite query result with items, pagination controls, and loading states
*
* @example
- * const { items, hasNext, loadNext, isLoading } = useInfiniteQuery('/items', {
- * limit: 20,
- * mode: 'cursor' // or 'offset'
+ * // Basic infinite scroll
+ * const { items, hasNext, loadNext, isLoading } = useInfiniteQuery('/messages')
+ *
+ * return (
+ *
+ * {items.map(item => )}
+ * {hasNext && }
+ *
+ * )
+ *
+ * @example
+ * // With filters and custom limit
+ * const { items, loadNext } = useInfiniteQuery('/messages', {
+ * query: { topicId: 'abc' },
+ * limit: 50
* })
*/
export function useInfiniteQuery(
path: TPath,
options?: {
- /** Additional query parameters (excluding pagination params) */
- query?: Omit, 'page' | 'limit' | 'cursor'>
+ /** Additional query parameters (cursor/limit are managed internally) */
+ query?: Omit, 'cursor' | 'limit'>
/** Items per page (default: 10) */
limit?: number
- /** Pagination mode (default: 'cursor') */
- mode?: 'offset' | 'cursor'
- /** Whether to enable the query (default: true) */
+ /** Set to false to disable fetching (default: true) */
enabled?: boolean
- /** SWR options (including initialSize, revalidateAll, etc.) */
- swrOptions?: Parameters[2]
+ /** Override SWR infinite configuration */
+ swrOptions?: SWRInfiniteConfiguration
}
): UseInfiniteQueryResult> {
const limit = options?.limit ?? 10
- const mode = options?.mode ?? 'cursor' // Default: cursor mode
const enabled = options?.enabled !== false
const getKey = useCallback(
- (pageIndex: number, previousPageData: PaginationResponse | null) => {
+ (_pageIndex: number, previousPageData: CursorPaginationResponse | null) => {
if (!enabled) return null
- if (previousPageData) {
- if (mode === 'cursor') {
- if (!isCursorPaginationResponse(previousPageData) || !previousPageData.nextCursor) {
- return null
- }
- } else {
- // Offset mode: check if we've reached the end
- if (isCursorPaginationResponse(previousPageData)) {
- return null
- }
- const offsetData = previousPageData as OffsetPaginationResponse
- // No more pages if items returned is less than limit or we've fetched all
- if (offsetData.items.length < limit || pageIndex * limit >= offsetData.total) {
- return null
- }
- }
+ // Stop if previous page has no nextCursor
+ if (previousPageData && !previousPageData.nextCursor) {
+ return null
}
- const paginationQuery: Record = {
- ...(options?.query as Record),
- limit
+ const paginationQuery = {
+ ...options?.query,
+ limit,
+ ...(previousPageData?.nextCursor ? { cursor: previousPageData.nextCursor } : {})
}
- if (mode === 'cursor' && previousPageData && isCursorPaginationResponse(previousPageData)) {
- paginationQuery.cursor = previousPageData.nextCursor
- } else if (mode === 'offset') {
- paginationQuery.page = pageIndex + 1
- }
-
- return [path, paginationQuery] as [TPath, Record]
+ return [path, paginationQuery] as [TPath, typeof paginationQuery]
},
- [path, options?.query, limit, mode, enabled]
+ [path, options?.query, limit, enabled]
)
- const infiniteFetcher = (key: [ConcreteApiPaths, Record?]) => {
- return getFetcher(key) as Promise>
+ const infiniteFetcher = (key: [TPath, Record]) => {
+ return getFetcher(key as unknown as [TPath, QueryParamsForPath?]) as Promise<
+ CursorPaginationResponse>
+ >
}
const swrResult = useSWRInfinite(getKey, infiniteFetcher, {
...DEFAULT_SWR_OPTIONS,
- initialSize: 1,
- revalidateAll: false,
- revalidateFirstPage: true,
- parallel: false,
...options?.swrOptions
})
- const { error, isLoading, isValidating, mutate, size, setSize } = swrResult
- const data = swrResult.data as PaginationResponse[] | undefined
+ const { error, isLoading, isValidating, mutate, setSize } = swrResult
+ const data = swrResult.data as CursorPaginationResponse>[] | undefined
const items = useMemo(() => data?.flatMap((p) => p.items) ?? [], [data])
const hasNext = useMemo(() => {
if (!data?.length) return false
const last = data[data.length - 1]
- if (mode === 'cursor') {
- return isCursorPaginationResponse(last) && !!last.nextCursor
- }
- // Offset mode: check if there are more items
- if (isCursorPaginationResponse(last)) return false
- const offsetData = last as OffsetPaginationResponse
- return offsetData.page * limit < offsetData.total
- }, [data, mode, limit])
+ return !!last.nextCursor
+ }, [data])
const loadNext = useCallback(() => {
if (!hasNext || isValidating) return
@@ -443,29 +525,17 @@ export function useInfiniteQuery(
const refresh = useCallback(() => mutate(), [mutate])
const reset = useCallback(() => setSize(1), [setSize])
- // Total is only available in offset mode
- const total = useMemo(() => {
- if (!data?.length) return 0
- const first = data[0]
- if (isCursorPaginationResponse(first)) return 0
- return (first as OffsetPaginationResponse).total
- }, [data])
-
return {
items,
- pages: data ?? [],
- total,
- size,
isLoading,
isRefreshing: isValidating,
error: error as Error | undefined,
hasNext,
loadNext,
- setSize,
refresh,
reset,
mutate
- } as UseInfiniteQueryResult>
+ }
}
// ============================================================================
@@ -473,25 +543,50 @@ export function useInfiniteQuery(
// ============================================================================
/**
- * Paginated data fetching hook with navigation controls
+ * Paginated data fetching hook with offset-based navigation.
+ *
+ * Provides page-by-page navigation with previous/next controls.
+ * Automatically resets to page 1 when query parameters change.
+ *
+ * @param path - API endpoint path (must return OffsetPaginationResponse)
+ * @param options - Pagination options
+ * @param options.query - Additional query parameters (page/limit are managed internally)
+ * @param options.limit - Items per page (default: 10)
+ * @param options.enabled - Set to false to disable fetching (default: true)
+ * @param options.swrOptions - Override SWR configuration
+ * @returns Paginated query result with items, page info, and navigation controls
*
* @example
- * const { items, page, hasNext, nextPage, prevPage } = usePaginatedQuery('/items', {
- * limit: 20,
- * query: { search: 'hello' }
+ * // Basic pagination
+ * const { items, page, hasNext, hasPrev, nextPage, prevPage } = usePaginatedQuery('/topics')
+ *
+ * return (
+ *
+ * {items.map(item => )}
+ *
+ * Page {page}
+ *
+ *
+ * )
+ *
+ * @example
+ * // With search filter
+ * const { items, total } = usePaginatedQuery('/topics', {
+ * query: { search: searchTerm },
+ * limit: 20
* })
*/
export function usePaginatedQuery(
path: TPath,
options?: {
- /** Additional query parameters (excluding pagination params) */
+ /** Additional query parameters (page/limit are managed internally) */
query?: Omit, 'page' | 'limit'>
/** Items per page (default: 10) */
limit?: number
- /** Whether to enable the query (default: true) */
+ /** Set to false to disable fetching (default: true) */
enabled?: boolean
- /** SWR options */
- swrOptions?: Parameters[2]
+ /** Override SWR configuration */
+ swrOptions?: SWRConfiguration
}
): UsePaginatedQueryResult> {
const [currentPage, setCurrentPage] = useState(1)
@@ -503,13 +598,15 @@ export function usePaginatedQuery(
setCurrentPage(1)
}, [queryKey])
+ // Build query with pagination params
const queryWithPagination = {
...options?.query,
page: currentPage,
limit
- } as Record
+ }
const { data, isLoading, isRefreshing, error, refetch } = useQuery(path, {
+ // Type assertion needed: we're adding pagination params to a partial query type
query: queryWithPagination as QueryParamsForPath,
enabled: options?.enabled,
swrOptions: options?.swrOptions
@@ -555,3 +652,80 @@ export function usePaginatedQuery(
reset
} as UsePaginatedQueryResult>
}
+
+// ============================================================================
+// Internal Utilities
+// ============================================================================
+
+/**
+ * Create a type-safe API fetcher for the specified HTTP method.
+ *
+ * @internal
+ * @param method - HTTP method to use
+ * @returns Async function that makes the API request
+ *
+ * @remarks
+ * Type assertion at dataApiService boundary is intentional since dataApiService
+ * accepts 'any' for maximum flexibility.
+ */
+function createApiFetcher(
+ method: TMethod
+) {
+ return async (
+ path: TPath,
+ options?: {
+ body?: BodyForPath
+ query?: QueryParamsForPath
+ }
+ ): Promise> => {
+ // Internal type assertion for dataApiService boundary (accepts any)
+ const query = options?.query as Record | undefined
+ switch (method) {
+ case 'GET':
+ return dataApiService.get(path, { query })
+ case 'POST':
+ return dataApiService.post(path, { body: options?.body, query })
+ case 'PUT':
+ return dataApiService.put(path, { body: options?.body || {}, query })
+ case 'DELETE':
+ return dataApiService.delete(path, { query })
+ case 'PATCH':
+ return dataApiService.patch(path, { body: options?.body, query })
+ default:
+ throw new Error(`Unsupported method: ${method}`)
+ }
+ }
+}
+
+/**
+ * Build SWR cache key from path and optional query parameters.
+ *
+ * @internal
+ * @param path - API endpoint path
+ * @param query - Optional query parameters
+ * @returns Tuple of [path] or [path, query] for SWR cache key
+ */
+function buildSWRKey>(
+ path: TPath,
+ query?: TQuery
+): [TPath] | [TPath, TQuery] {
+ if (query && Object.keys(query).length > 0) {
+ return [path, query]
+ }
+
+ return [path]
+}
+
+/**
+ * SWR fetcher function for GET requests.
+ *
+ * @internal
+ * @param key - SWR cache key tuple [path, query?]
+ * @returns Promise resolving to the API response
+ */
+function getFetcher([path, query]: [TPath, QueryParamsForPath?]): Promise<
+ ResponseForPath
+> {
+ const apiFetcher = createApiFetcher('GET')
+ return apiFetcher(path, { query })
+}
diff --git a/tests/__mocks__/renderer/useDataApi.ts b/tests/__mocks__/renderer/useDataApi.ts
index 760a134583..03d9b71cc0 100644
--- a/tests/__mocks__/renderer/useDataApi.ts
+++ b/tests/__mocks__/renderer/useDataApi.ts
@@ -46,7 +46,7 @@ function createMockDataForPath(path: ConcreteApiPaths): any {
/**
* Mock useQuery hook
- * Matches actual signature: useQuery(path, options?) => { data, loading, error, refetch, mutate }
+ * Matches actual signature: useQuery(path, options?) => { data, isLoading, isRefreshing, error, refetch, mutate }
*/
export const mockUseQuery = vi.fn(
(
@@ -58,7 +58,8 @@ export const mockUseQuery = vi.fn(
}
): {
data?: ResponseForPath
- loading: boolean
+ isLoading: boolean
+ isRefreshing: boolean
error?: Error
refetch: () => void
mutate: KeyedMutator>
@@ -67,7 +68,8 @@ export const mockUseQuery = vi.fn(
if (options?.enabled === false) {
return {
data: undefined,
- loading: false,
+ isLoading: false,
+ isRefreshing: false,
error: undefined,
refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(undefined) as unknown as KeyedMutator>
@@ -78,7 +80,8 @@ export const mockUseQuery = vi.fn(
return {
data: mockData as ResponseForPath,
- loading: false,
+ isLoading: false,
+ isRefreshing: false,
error: undefined,
refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(mockData) as unknown as KeyedMutator>
@@ -88,7 +91,7 @@ export const mockUseQuery = vi.fn(
/**
* Mock useMutation hook
- * Matches actual signature: useMutation(method, path, options?) => { mutate, loading, error }
+ * Matches actual signature: useMutation(method, path, options?) => { trigger, isLoading, error }
*/
export const mockUseMutation = vi.fn(
(
@@ -97,19 +100,19 @@ export const mockUseMutation = vi.fn(
_options?: {
onSuccess?: (data: ResponseForPath) => void
onError?: (error: Error) => void
- revalidate?: boolean | string[]
- optimistic?: boolean
+ refresh?: ConcreteApiPaths[]
optimisticData?: ResponseForPath
+ swrOptions?: any
}
): {
- mutate: (data?: {
+ trigger: (data?: {
body?: BodyForPath
query?: QueryParamsForPath
}) => Promise>
- loading: boolean
+ isLoading: boolean
error: Error | undefined
} => {
- const mockMutate = vi.fn(
+ const mockTrigger = vi.fn(
async (_data?: { body?: BodyForPath; query?: QueryParamsForPath }) => {
// Simulate different responses based on method
switch (method) {
@@ -127,8 +130,8 @@ export const mockUseMutation = vi.fn(
)
return {
- mutate: mockMutate,
- loading: false,
+ trigger: mockTrigger,
+ isLoading: false,
error: undefined
}
}
@@ -136,7 +139,7 @@ export const mockUseMutation = vi.fn(
/**
* Mock usePaginatedQuery hook
- * Matches actual signature: usePaginatedQuery(path, options?) => { items, total, page, loading, error, hasMore, hasPrev, prevPage, nextPage, refresh, reset }
+ * Matches actual signature: usePaginatedQuery(path, options?) => { items, total, page, isLoading, isRefreshing, error, hasNext, hasPrev, prevPage, nextPage, refresh, reset }
*/
export const mockUsePaginatedQuery = vi.fn(
(
@@ -151,9 +154,10 @@ export const mockUsePaginatedQuery = vi.fn(
items: T[]
total: number
page: number
- loading: boolean
+ isLoading: boolean
+ isRefreshing: boolean
error?: Error
- hasMore: boolean
+ hasNext: boolean
hasPrev: boolean
prevPage: () => void
nextPage: () => void
@@ -173,9 +177,10 @@ export const mockUsePaginatedQuery = vi.fn(
items: mockItems,
total: mockItems.length,
page: 1,
- loading: false,
+ isLoading: false,
+ isRefreshing: false,
error: undefined,
- hasMore: false,
+ hasNext: false,
hasPrev: false,
prevPage: vi.fn(),
nextPage: vi.fn(),
@@ -186,9 +191,10 @@ export const mockUsePaginatedQuery = vi.fn(
items: T[]
total: number
page: number
- loading: boolean
+ isLoading: boolean
+ isRefreshing: boolean
error?: Error
- hasMore: boolean
+ hasNext: boolean
hasPrev: boolean
prevPage: () => void
nextPage: () => void
@@ -259,7 +265,8 @@ export const MockUseDataApiUtils = {
if (queryPath === path) {
return {
data,
- loading: false,
+ isLoading: false,
+ isRefreshing: false,
error: undefined,
refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(data)
@@ -269,7 +276,8 @@ export const MockUseDataApiUtils = {
const defaultData = createMockDataForPath(queryPath)
return {
data: defaultData,
- loading: false,
+ isLoading: false,
+ isRefreshing: false,
error: undefined,
refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(defaultData)
@@ -285,7 +293,8 @@ export const MockUseDataApiUtils = {
if (queryPath === path) {
return {
data: undefined,
- loading: true,
+ isLoading: true,
+ isRefreshing: false,
error: undefined,
refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(undefined)
@@ -294,7 +303,8 @@ export const MockUseDataApiUtils = {
const defaultData = createMockDataForPath(queryPath)
return {
data: defaultData,
- loading: false,
+ isLoading: false,
+ isRefreshing: false,
error: undefined,
refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(defaultData)
@@ -310,7 +320,8 @@ export const MockUseDataApiUtils = {
if (queryPath === path) {
return {
data: undefined,
- loading: false,
+ isLoading: false,
+ isRefreshing: false,
error,
refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(undefined)
@@ -319,7 +330,8 @@ export const MockUseDataApiUtils = {
const defaultData = createMockDataForPath(queryPath)
return {
data: defaultData,
- loading: false,
+ isLoading: false,
+ isRefreshing: false,
error: undefined,
refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(defaultData)
@@ -338,15 +350,15 @@ export const MockUseDataApiUtils = {
mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => {
if (mutationPath === path && mutationMethod === method) {
return {
- mutate: vi.fn().mockResolvedValue(result),
- loading: false,
+ trigger: vi.fn().mockResolvedValue(result),
+ isLoading: false,
error: undefined
}
}
// Default behavior
return {
- mutate: vi.fn().mockResolvedValue({ success: true }),
- loading: false,
+ trigger: vi.fn().mockResolvedValue({ success: true }),
+ isLoading: false,
error: undefined
}
})
@@ -363,15 +375,15 @@ export const MockUseDataApiUtils = {
mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => {
if (mutationPath === path && mutationMethod === method) {
return {
- mutate: vi.fn().mockRejectedValue(error),
- loading: false,
+ trigger: vi.fn().mockRejectedValue(error),
+ isLoading: false,
error: undefined
}
}
// Default behavior
return {
- mutate: vi.fn().mockResolvedValue({ success: true }),
- loading: false,
+ trigger: vi.fn().mockResolvedValue({ success: true }),
+ isLoading: false,
error: undefined
}
})
@@ -387,15 +399,15 @@ export const MockUseDataApiUtils = {
mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => {
if (mutationPath === path && mutationMethod === method) {
return {
- mutate: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
- loading: true,
+ trigger: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
+ isLoading: true,
error: undefined
}
}
// Default behavior
return {
- mutate: vi.fn().mockResolvedValue({ success: true }),
- loading: false,
+ trigger: vi.fn().mockResolvedValue({ success: true }),
+ isLoading: false,
error: undefined
}
})
@@ -407,7 +419,7 @@ export const MockUseDataApiUtils = {
mockPaginatedData: (
path: TPath,
items: any[],
- options?: { total?: number; page?: number; hasMore?: boolean; hasPrev?: boolean }
+ options?: { total?: number; page?: number; hasNext?: boolean; hasPrev?: boolean }
) => {
mockUsePaginatedQuery.mockImplementation((queryPath, _queryOptions) => {
if (queryPath === path) {
@@ -415,9 +427,10 @@ export const MockUseDataApiUtils = {
items,
total: options?.total ?? items.length,
page: options?.page ?? 1,
- loading: false,
+ isLoading: false,
+ isRefreshing: false,
error: undefined,
- hasMore: options?.hasMore ?? false,
+ hasNext: options?.hasNext ?? false,
hasPrev: options?.hasPrev ?? false,
prevPage: vi.fn(),
nextPage: vi.fn(),
@@ -430,9 +443,10 @@ export const MockUseDataApiUtils = {
items: [],
total: 0,
page: 1,
- loading: false,
+ isLoading: false,
+ isRefreshing: false,
error: undefined,
- hasMore: false,
+ hasNext: false,
hasPrev: false,
prevPage: vi.fn(),
nextPage: vi.fn(),