useInfiniteQ
useInfiniteQ is useQ’s big sibling for paginated routes. It only accepts routes whose RouteDefinition declares a pagination block — TypeScript prevents you from calling it on a non-paginated route.
A paginated route
Section titled “A paginated route”import type { Schema } from "@use-q/api-client";
export const schema = { listPosts: { method: "GET", path: "/facilities/{facilityId}/posts", pathParams: {} as { facilityId: string }, searchParams: {} as { page?: number; limit?: number; search?: string }, response: {} as { items: Post[]; total: number }, pagination: { kind: "page-number", pageParam: "page", itemsField: "items", totalField: "total", }, tags: [{ type: "post", facilityId: (p) => p.facilityId }], },} as const satisfies Schema;The pagination block tells useInfiniteQ two things:
- Which
searchParamskey is the cursor (pageParamorcursorParam). - How to find items + total/next-cursor in the response.
Using it
Section titled “Using it”import { useInfiniteQ } from "@/api/client";
function PostList({ facilityId }: { facilityId: string }) { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, } = useInfiniteQ("listPosts", { pathParams: { facilityId }, searchParams: { limit: 20 }, initialPageParam: 1, getNextPageParam: (lastPage, allPages) => { const fetched = allPages.reduce((sum, p) => sum + p.items.length, 0); return fetched < lastPage.total ? allPages.length + 1 : undefined; }, });
if (isLoading) return <p>Loading…</p>;
return ( <> {data?.pages.flatMap((page) => page.items.map((post) => <article key={post.id}>{post.title}</article>), )} {hasNextPage && ( <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}> {isFetchingNextPage ? "Loading more…" : "Load more"} </button> )} </> );}use-q injects the current page param into the schema’s pageParam key on every fetch. You can read it inside getNextPageParam or select if you need.
Cursor pagination
Section titled “Cursor pagination”listFeed: { method: "GET", path: "/feed", searchParams: {} as { cursor?: string; limit?: number }, response: {} as { items: Post[]; nextCursor: string | null }, pagination: { kind: "cursor", cursorParam: "cursor", itemsField: "items", nextCursorField: "nextCursor", },}const feed = useInfiniteQ("listFeed", { searchParams: { limit: 25 }, initialPageParam: null as string | null, getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,});When getNextPageParam returns undefined, hasNextPage becomes false.
pages aggregation
Section titled “pages aggregation”data.pages is an array of raw responses (one per fetched page). To render a flat list, .flatMap through them:
{data?.pages.flatMap((p) => p.items).map((post) => ( <article key={post.id}>{post.title}</article>))}If you’d rather expose a derived shape to the component, use select:
const posts = useInfiniteQ("listPosts", { pathParams: { facilityId }, initialPageParam: 1, getNextPageParam: (last, all) => all.flatMap((p) => p.items).length < last.total ? all.length + 1 : undefined, select: (data) => ({ posts: data.pages.flatMap((p) => p.items), total: data.pages[0]?.total ?? 0, }),});
posts.data?.posts; // Post[]posts.data?.total; // numberScroll-trigger pattern
Section titled “Scroll-trigger pattern”A common UX: load more whenever a sentinel scrolls into view. Combine with IntersectionObserver:
import { useEffect, useRef } from "react";
function PostList({ facilityId }: { facilityId: string }) { const sentinelRef = useRef<HTMLDivElement | null>(null); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQ("listPosts", { pathParams: { facilityId }, initialPageParam: 1, getNextPageParam: (last, all) => all.reduce((s, p) => s + p.items.length, 0) < last.total ? all.length + 1 : undefined, });
useEffect(() => { const el = sentinelRef.current; if (!el || !hasNextPage) return; const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !isFetchingNextPage) { void fetchNextPage(); } }); obs.observe(el); return () => obs.disconnect(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return ( <> {data?.pages.flatMap((p) => p.items.map((post) => <article key={post.id}>{post.title}</article>), )} <div ref={sentinelRef} aria-hidden style={{ height: 1 }} /> </> );}Options
Section titled “Options”useInfiniteQ accepts everything useQ does, plus:
interface UseInfiniteQOptions<TData, TPageParam> { initialPageParam: TPageParam; getNextPageParam: ( lastPage: TData, allPages: TData[], lastPageParam: TPageParam, allPageParams: TPageParam[], ) => TPageParam | undefined | null; getPreviousPageParam?: ( firstPage: TData, allPages: TData[], firstPageParam: TPageParam, allPageParams: TPageParam[], ) => TPageParam | undefined | null; maxPages?: number;}maxPages
Section titled “maxPages”Cap how many pages stay in memory; useful for very long lists where dropping older pages is OK:
useInfiniteQ("listFeed", { initialPageParam: null, getNextPageParam: (l) => l.nextCursor ?? undefined, maxPages: 10,});Type-only guard
Section titled “Type-only guard”The hook’s first argument is constrained to routes with a pagination block. Misuse fails at compile time:
useInfiniteQ("getPost", { // ^^^^^^^^^ Type '"getPost"' is not assignable to type 'PaginatedRouteKey<…>' pathParams: { facilityId, postId },});