Files
flyer-crawler.projectium.com/src/hooks/useInfiniteQuery.ts
Torben Sorensen 510787bc5b
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 42s
fix tests + flyer upload (anon)
2025-12-30 10:32:58 -08:00

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,
};
}