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.
This commit is contained in:
fullex 2026-01-05 00:12:01 +08:00
parent 7cac5b55f6
commit 6567d9d255
3 changed files with 518 additions and 314 deletions

View File

@ -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 <Loading />
if (isLoading) return <Loading />
if (error) {
if (error.code === ErrorCode.NOT_FOUND) {
return <NotFound />
@ -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 (
<>
<List items={data?.items ?? []} />
<Pagination
current={page}
total={data?.total ?? 0}
onChange={setPage}
/>
</>
)
}
```
### 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 (
<form onSubmit={handleSubmit}>
{/* form fields */}
<button disabled={isMutating}>
{isMutating ? 'Creating...' : 'Create'}
<button disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create'}
</button>
</form>
)
@ -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<Topic>
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

View File

@ -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<TPath extends ConcreteApiPaths> = ResponseForPath<TPath,
? T
: unknown
/** useQuery result type */
/**
* useQuery result type
* @property data - The fetched data, undefined while loading or on error
* @property isLoading - True during initial load (no cached data)
* @property isRefreshing - True during background revalidation (has cached data)
* @property error - Error object if the request failed
* @property refetch - Trigger a revalidation from the server
* @property mutate - SWR mutator for advanced cache control (optimistic updates, manual cache manipulation)
*/
export interface UseQueryResult<TPath extends ConcreteApiPaths> {
data?: ResponseForPath<TPath, 'GET'>
isLoading: boolean
@ -34,12 +87,17 @@ export interface UseQueryResult<TPath extends ConcreteApiPaths> {
mutate: KeyedMutator<ResponseForPath<TPath, 'GET'>>
}
/** 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<TPath, TMethod>
query?: QueryParamsForPath<TPath>
}) => Promise<ResponseForPath<TPath, TMethod>>
@ -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<T> {
items: T[]
pages: PaginationResponse<T>[]
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<PaginationResponse<T>[]>
mutate: KeyedMutator<CursorPaginationResponse<T>[]>
}
/** 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<T> {
items: T[]
total: number
@ -80,94 +159,50 @@ export interface UsePaginatedQueryResult<T> {
reset: () => void
}
// ============================================================================
// Utilities
// ============================================================================
/**
* Unified API fetcher with type-safe method dispatching
*/
function createApiFetcher<TPath extends ConcreteApiPaths, TMethod extends 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
method: TMethod
) {
return async (
path: TPath,
options?: {
body?: BodyForPath<TPath, TMethod>
query?: Record<string, any>
}
): Promise<ResponseForPath<TPath, TMethod>> => {
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<TPath extends ConcreteApiPaths>(
path: TPath,
query?: Record<string, any>
): [TPath, Record<string, any>?] | null {
if (query && Object.keys(query).length > 0) {
return [path, query]
}
return [path]
}
/**
* GET request fetcher for SWR
*/
function getFetcher<TPath extends ConcreteApiPaths>([path, query]: [TPath, Record<string, any>?]): Promise<
ResponseForPath<TPath, 'GET'>
> {
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<TPath extends ConcreteApiPaths>(
path: TPath,
options?: {
/** Query parameters for filtering, pagination, etc. */
query?: QueryParamsForPath<TPath>
/** Disable the request */
/** Disable the request (default: true) */
enabled?: boolean
/** Custom SWR options */
swrOptions?: Parameters<typeof useSWR>[2]
/** Override default SWR configuration */
swrOptions?: SWRConfiguration
}
): UseQueryResult<TPath> {
// Internal type conversion for SWR compatibility
const key = options?.enabled !== false ? buildSWRKey(path, options?.query as Record<string, any>) : 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<TPath extends ConcreteApiPaths>(
}
/**
* 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<TPath extends ConcreteApiPaths, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
method: TMethod,
path: TPath,
options?: {
/** Called when mutation succeeds */
/** Callback when mutation succeeds */
onSuccess?: (data: ResponseForPath<TPath, TMethod>) => 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<TPath, TMethod>
/** Override SWR mutation configuration (fetcher, onSuccess, onError are handled internally) */
swrOptions?: Omit<
SWRMutationConfiguration<ResponseForPath<TPath, TMethod>, Error>,
'fetcher' | 'onSuccess' | 'onError'
>
}
): UseMutationResult<TPath, TMethod> {
const { mutate: globalMutate } = useSWRConfig()
@ -220,7 +285,7 @@ export function useMutation<TPath extends ConcreteApiPaths, TMethod extends 'POS
optionsRef.current = options
}, [options])
const apiFetcher = createApiFetcher(method)
const apiFetcher = createApiFetcher<TPath, TMethod>(method)
const fetcher = async (
_key: string,
@ -229,79 +294,87 @@ export function useMutation<TPath extends ConcreteApiPaths, TMethod extends 'POS
}: {
arg?: {
body?: BodyForPath<TPath, TMethod>
query?: Record<string, any>
query?: QueryParamsForPath<TPath>
}
}
): Promise<ResponseForPath<TPath, TMethod>> => {
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<TPath, TMethod>
query?: QueryParamsForPath<TPath>
}): Promise<ResponseForPath<TPath, TMethod>> => {
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<string, any> } : 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<TPath, TMethod>
query?: QueryParamsForPath<TPath>
}): Promise<ResponseForPath<TPath, TMethod>> => {
const convertedData = data ? { body: data.body, query: data.query as Record<string, any> } : 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<TPath extends ConcreteApiPaths>(
path: TPath,
@ -331,8 +419,8 @@ export function prefetch<TPath extends ConcreteApiPaths>(
query?: QueryParamsForPath<TPath>
}
): Promise<ResponseForPath<TPath, 'GET'>> {
const apiFetcher = createApiFetcher('GET')
return apiFetcher(path, { query: options?.query as Record<string, any> })
const key = buildSWRKey(path, options?.query)
return preload(key, getFetcher)
}
// ============================================================================
@ -340,100 +428,94 @@ export function prefetch<TPath extends ConcreteApiPaths>(
// ============================================================================
/**
* 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 (
* <div>
* {items.map(item => <Item key={item.id} {...item} />)}
* {hasNext && <button onClick={loadNext}>Load More</button>}
* </div>
* )
*
* @example
* // With filters and custom limit
* const { items, loadNext } = useInfiniteQuery('/messages', {
* query: { topicId: 'abc' },
* limit: 50
* })
*/
export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
path: TPath,
options?: {
/** Additional query parameters (excluding pagination params) */
query?: Omit<QueryParamsForPath<TPath>, 'page' | 'limit' | 'cursor'>
/** Additional query parameters (cursor/limit are managed internally) */
query?: Omit<QueryParamsForPath<TPath>, '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<typeof useSWRInfinite>[2]
/** Override SWR infinite configuration */
swrOptions?: SWRInfiniteConfiguration
}
): UseInfiniteQueryResult<InferPaginatedItem<TPath>> {
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<any> | null) => {
(_pageIndex: number, previousPageData: CursorPaginationResponse<unknown> | 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<any>
// 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<string, any> = {
...(options?.query as Record<string, any>),
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<string, any>]
return [path, paginationQuery] as [TPath, typeof paginationQuery]
},
[path, options?.query, limit, mode, enabled]
[path, options?.query, limit, enabled]
)
const infiniteFetcher = (key: [ConcreteApiPaths, Record<string, any>?]) => {
return getFetcher(key) as Promise<PaginationResponse<any>>
const infiniteFetcher = (key: [TPath, Record<string, unknown>]) => {
return getFetcher(key as unknown as [TPath, QueryParamsForPath<TPath>?]) as Promise<
CursorPaginationResponse<InferPaginatedItem<TPath>>
>
}
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<any>[] | undefined
const { error, isLoading, isValidating, mutate, setSize } = swrResult
const data = swrResult.data as CursorPaginationResponse<InferPaginatedItem<TPath>>[] | 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<any>
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<TPath extends ConcreteApiPaths>(
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<any>).total
}, [data])
return {
items,
pages: data ?? [],
total,
size,
isLoading,
isRefreshing: isValidating,
error: error as Error | undefined,
hasNext,
loadNext,
setSize,
refresh,
reset,
mutate
} as UseInfiniteQueryResult<InferPaginatedItem<TPath>>
}
}
// ============================================================================
@ -473,25 +543,50 @@ export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
// ============================================================================
/**
* 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 (
* <div>
* {items.map(item => <Item key={item.id} {...item} />)}
* <button onClick={prevPage} disabled={!hasPrev}>Prev</button>
* <span>Page {page}</span>
* <button onClick={nextPage} disabled={!hasNext}>Next</button>
* </div>
* )
*
* @example
* // With search filter
* const { items, total } = usePaginatedQuery('/topics', {
* query: { search: searchTerm },
* limit: 20
* })
*/
export function usePaginatedQuery<TPath extends ConcreteApiPaths>(
path: TPath,
options?: {
/** Additional query parameters (excluding pagination params) */
/** Additional query parameters (page/limit are managed internally) */
query?: Omit<QueryParamsForPath<TPath>, '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<typeof useSWR>[2]
/** Override SWR configuration */
swrOptions?: SWRConfiguration
}
): UsePaginatedQueryResult<InferPaginatedItem<TPath>> {
const [currentPage, setCurrentPage] = useState(1)
@ -503,13 +598,15 @@ export function usePaginatedQuery<TPath extends ConcreteApiPaths>(
setCurrentPage(1)
}, [queryKey])
// Build query with pagination params
const queryWithPagination = {
...options?.query,
page: currentPage,
limit
} as Record<string, any>
}
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<TPath>,
enabled: options?.enabled,
swrOptions: options?.swrOptions
@ -555,3 +652,80 @@ export function usePaginatedQuery<TPath extends ConcreteApiPaths>(
reset
} as UsePaginatedQueryResult<InferPaginatedItem<TPath>>
}
// ============================================================================
// 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<TPath extends ConcreteApiPaths, TMethod extends 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
method: TMethod
) {
return async (
path: TPath,
options?: {
body?: BodyForPath<TPath, TMethod>
query?: QueryParamsForPath<TPath>
}
): Promise<ResponseForPath<TPath, TMethod>> => {
// Internal type assertion for dataApiService boundary (accepts any)
const query = options?.query as Record<string, unknown> | 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<TPath extends ConcreteApiPaths, TQuery extends QueryParamsForPath<TPath>>(
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<TPath extends ConcreteApiPaths>([path, query]: [TPath, QueryParamsForPath<TPath>?]): Promise<
ResponseForPath<TPath, 'GET'>
> {
const apiFetcher = createApiFetcher<TPath, 'GET'>('GET')
return apiFetcher(path, { query })
}

View File

@ -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(
<TPath extends ConcreteApiPaths>(
@ -58,7 +58,8 @@ export const mockUseQuery = vi.fn(
}
): {
data?: ResponseForPath<TPath, 'GET'>
loading: boolean
isLoading: boolean
isRefreshing: boolean
error?: Error
refetch: () => void
mutate: KeyedMutator<ResponseForPath<TPath, 'GET'>>
@ -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<ResponseForPath<TPath, 'GET'>>
@ -78,7 +80,8 @@ export const mockUseQuery = vi.fn(
return {
data: mockData as ResponseForPath<TPath, 'GET'>,
loading: false,
isLoading: false,
isRefreshing: false,
error: undefined,
refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(mockData) as unknown as KeyedMutator<ResponseForPath<TPath, 'GET'>>
@ -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(
<TPath extends ConcreteApiPaths, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
@ -97,19 +100,19 @@ export const mockUseMutation = vi.fn(
_options?: {
onSuccess?: (data: ResponseForPath<TPath, TMethod>) => void
onError?: (error: Error) => void
revalidate?: boolean | string[]
optimistic?: boolean
refresh?: ConcreteApiPaths[]
optimisticData?: ResponseForPath<TPath, TMethod>
swrOptions?: any
}
): {
mutate: (data?: {
trigger: (data?: {
body?: BodyForPath<TPath, TMethod>
query?: QueryParamsForPath<TPath>
}) => Promise<ResponseForPath<TPath, TMethod>>
loading: boolean
isLoading: boolean
error: Error | undefined
} => {
const mockMutate = vi.fn(
const mockTrigger = vi.fn(
async (_data?: { body?: BodyForPath<TPath, TMethod>; query?: QueryParamsForPath<TPath> }) => {
// 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(
<TPath extends ConcreteApiPaths>(
@ -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: <TPath extends ConcreteApiPaths>(
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(),