// 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 { 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; 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( apiFunction: ApiFunction, options: UseInfiniteQueryOptions = {}, ) { const { initialCursor = 0 } = options; const [data, setData] = useState([]); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); // For the very first fetch const [isFetchingNextPage, setIsFetchingNextPage] = useState(false); // For subsequent fetches const [isRefetching, setIsRefetching] = useState(false); const [hasNextPage, setHasNextPage] = useState(true); // Use a ref to store the cursor for the next page. const nextCursorRef = useRef(initialCursor); const lastErrorMessageRef = useRef(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 = 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, }; }