Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 42s
149 lines
4.9 KiB
TypeScript
149 lines
4.9 KiB
TypeScript
// src/hooks/useInfiniteQuery.ts
|
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import { logger } from '../services/logger.client';
|
|
import { notifyError } from '../services/notificationService';
|
|
|
|
/**
|
|
* The expected shape of a paginated API response.
|
|
* The `items` array holds the data for the current page.
|
|
* The `nextCursor` is an identifier (like an offset or page number) for the next set of data.
|
|
*/
|
|
export interface PaginatedResponse<T> {
|
|
items: T[];
|
|
nextCursor?: number | string | null;
|
|
}
|
|
|
|
/**
|
|
* The type for the API function passed to the hook.
|
|
* It must accept a cursor/page parameter and return a `PaginatedResponse`.
|
|
*/
|
|
type ApiFunction = (cursor?: number | string | null) => Promise<Response>;
|
|
|
|
interface UseInfiniteQueryOptions {
|
|
initialCursor?: number | string | null;
|
|
}
|
|
|
|
/**
|
|
* A custom hook for fetching and managing paginated data that accumulates over time.
|
|
* Ideal for "infinite scroll" or "load more" UI patterns.
|
|
*
|
|
* @template T The type of the individual items being fetched.
|
|
* @param apiFunction The API client function to execute for each page.
|
|
* @param options Configuration options for the query.
|
|
* @returns An object with state and methods for managing the infinite query.
|
|
*/
|
|
export function useInfiniteQuery<T>(
|
|
apiFunction: ApiFunction,
|
|
options: UseInfiniteQueryOptions = {},
|
|
) {
|
|
const { initialCursor = 0 } = options;
|
|
|
|
const [data, setData] = useState<T[]>([]);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
const [isLoading, setIsLoading] = useState<boolean>(true); // For the very first fetch
|
|
const [isFetchingNextPage, setIsFetchingNextPage] = useState<boolean>(false); // For subsequent fetches
|
|
const [isRefetching, setIsRefetching] = useState<boolean>(false);
|
|
const [hasNextPage, setHasNextPage] = useState<boolean>(true);
|
|
|
|
// Use a ref to store the cursor for the next page.
|
|
const nextCursorRef = useRef<number | string | null | undefined>(initialCursor);
|
|
const lastErrorMessageRef = useRef<string | null>(null);
|
|
|
|
const fetchPage = useCallback(
|
|
async (cursor?: number | string | null) => {
|
|
// Determine which loading state to set
|
|
const isInitialLoad = cursor === initialCursor && data.length === 0;
|
|
if (isInitialLoad) {
|
|
setIsLoading(true);
|
|
setIsRefetching(false);
|
|
} else {
|
|
setIsFetchingNextPage(true);
|
|
}
|
|
setError(null);
|
|
lastErrorMessageRef.current = null;
|
|
|
|
try {
|
|
const response = await apiFunction(cursor);
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = `Request failed with status ${response.status}: ${response.statusText}`;
|
|
try {
|
|
const errorData = await response.json();
|
|
if (Array.isArray(errorData.issues) && errorData.issues.length > 0) {
|
|
errorMessage = errorData.issues
|
|
.map(
|
|
(issue: { path?: string[]; message: string }) =>
|
|
`${issue.path?.join('.') || 'Error'}: ${issue.message}`,
|
|
)
|
|
.join('; ');
|
|
} else if (errorData.message) {
|
|
errorMessage = errorData.message;
|
|
}
|
|
} catch {
|
|
/* Ignore JSON parsing errors */
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
const page: PaginatedResponse<T> = await response.json();
|
|
|
|
// Append new items to the existing data
|
|
setData((prevData) =>
|
|
cursor === initialCursor ? page.items : [...prevData, ...page.items],
|
|
);
|
|
|
|
// Update cursor and hasNextPage status
|
|
nextCursorRef.current = page.nextCursor;
|
|
setHasNextPage(page.nextCursor != null);
|
|
} catch (e) {
|
|
const err = e instanceof Error ? e : new Error('An unknown error occurred.');
|
|
logger.error('API call failed in useInfiniteQuery hook', {
|
|
error: err.message,
|
|
functionName: apiFunction.name,
|
|
});
|
|
if (err.message !== lastErrorMessageRef.current) {
|
|
setError(err);
|
|
lastErrorMessageRef.current = err.message;
|
|
}
|
|
notifyError(err.message);
|
|
} finally {
|
|
setIsLoading(false);
|
|
setIsFetchingNextPage(false);
|
|
setIsRefetching(false);
|
|
}
|
|
},
|
|
[apiFunction, initialCursor],
|
|
);
|
|
|
|
// Fetch the initial page on mount
|
|
useEffect(() => {
|
|
fetchPage(initialCursor);
|
|
}, [fetchPage, initialCursor]);
|
|
|
|
// Function to be called by the UI to fetch the next page
|
|
const fetchNextPage = useCallback(() => {
|
|
if (hasNextPage && !isFetchingNextPage) {
|
|
fetchPage(nextCursorRef.current);
|
|
}
|
|
}, [fetchPage, hasNextPage, isFetchingNextPage]);
|
|
|
|
// Function to be called by the UI to refetch the entire query from the beginning.
|
|
const refetch = useCallback(() => {
|
|
setIsRefetching(true);
|
|
lastErrorMessageRef.current = null;
|
|
setData([]);
|
|
fetchPage(initialCursor);
|
|
}, [fetchPage, initialCursor]);
|
|
|
|
return {
|
|
data,
|
|
error,
|
|
isLoading,
|
|
isFetchingNextPage,
|
|
isRefetching,
|
|
hasNextPage,
|
|
fetchNextPage,
|
|
refetch,
|
|
};
|
|
}
|