The API returns the catalog twelve books at a time, as { books, nextPage, total }, with nextPage set to null on the last page. A phone catalog loads the next page as you scroll, which is what useInfiniteQuery is for: it keeps every loaded page under one key and knows how to ask for the next one:
const q = useInfiniteQuery({
queryKey: keys.books,
queryFn: ({ pageParam }) => getPage(pageParam),
initialPageParam: 0,
getNextPageParam: (last) => last.nextPage, // null means no more pages
});
const books = q.data?.pages.flatMap((p) => p.books) ?? [];
// ...
onEndReached={() => q.hasNextPage && !q.isFetchingNextPage && q.fetchNextPage()}
onEndReachedThreshold={0.5}Scrolling to the bottom of the catalog with adb shell input swipe fetched the remaining four pages, and the FlatList (Lists) grew each time:
LOG catalog: 36 books, status success, fetch fetching LOG catalog: 48 books, status success, fetch idle LOG catalog: 48 books, status success, fetch fetching LOG catalog: 60 books, status success, fetch idle 16:11:21 GET /books?page=1&limit=12 200 16:11:34 GET /books?page=2&limit=12 200 16:11:43 GET /books?page=3&limit=12 200 16:11:57 GET /books?page=4&limit=12 200
onEndReached can fire more than once near the end, hence the guard. Pull-to-refresh calls refetch(), which reloads every loaded page in order; maxPages caps how many are kept. For numbered pages, use useQuery with ['books', page] as the key and placeholderData: keepPreviousData, so the old page stays up while the next loads.