Skip to content

raf-query is early. The API can still change between minor releases, so use it at your own risk.

raf-query

Guides

Infinite queries

Paged queries with infiniteQueryOptions, "load more" buttons, infinite scroll, bidirectional lists and maxPages.

Lists that load more data onto what's already shown, such as "load more" buttons and infinite scroll, are a common UI pattern. A paged factory returns infiniteQueryOptions(...); everything else uses the same useQuery and the same verbs as any query.

src/queries.ts
export function 
function commentsQuery(input: {
    slug: string;
}): InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>
commentsQuery
(
input: {
    slug: string;
}
input
: { slug: stringslug: string }) {
return
infiniteQueryOptions<CommentPage, number, unknown, never, readonly ["comments", string], undefined>(options: Omit<InfiniteQueryOptions<{
    error: unknown;
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
    services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>
infiniteQueryOptions
({
queryKey: readonly ["comments", string]queryKey: ["comments",
input: {
    slug: string;
}
input
.slug: stringslug],
function queryFn(context: PageFunctionContext<number>): QueryFnResult<{
    data: CommentPage;
    error: unknown;
    services: never;
}>
queryFn
: (context: PageFunctionContext<number>context) =>
function listComments(input: {
    cursor: number;
    slug: string;
}): Promise<CommentPage>
listComments
({ slug: stringslug:
input: {
    slug: string;
}
input
.slug: stringslug, cursor: numbercursor: context: PageFunctionContext<number>context.PageFunctionContext<number>.pageParam: numberpageParam }),
initialPageParam: numberinitialPageParam: 0, function getNextPageParam(lastPage: CommentPage, pages: readonly CommentPage[], lastPageParam: number, pageParams: readonly number[]): number | null | undefinedgetNextPageParam: (lastPage: CommentPagelastPage) => lastPage: CommentPagelastPage.CommentPage.nextCursor: number | nullnextCursor, }); }
  • initialPageParam is the first page's param.
  • getNextPageParam(lastPage, pages, lastPageParam, pageParams) returns the next page's param, or null / undefined when there are no more pages.
  • The queryFn receives the param as context.pageParam.

Reading pages

With a paged factory, useQuery returns the same tuple. The promise resolves to { pages, pageParams }, and the controls add paging:

function 
function Comments(props: {
    handle: QueryHandle<typeof commentsQuery>;
}): React.JSX.Element
Comments
(
props: {
    handle: QueryHandle<typeof commentsQuery>;
}
props
: {
handle: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
handle
: interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandle<typeof
function commentsQuery(input: {
    slug: string;
}): InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>
commentsQuery
> }) {
const [
const pagesPromise: Thenable<InfiniteData<{
    page: CommentPage;
    pageParam: number;
}>>
pagesPromise
,
const comments: QueryControls<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>> & PagingControls
comments
] =
useQuery<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>, undefined>(factory: (input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>, handle: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>, options?: (Options & ... 1 more ... & {
    ...;
}) | undefined): readonly [promise: ...] (+1 overload)
useQuery
(
function commentsQuery(input: {
    slug: string;
}): InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>
commentsQuery
,
props: {
    handle: QueryHandle<typeof commentsQuery>;
}
props
.
handle: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
handle
);
const data =
use<InfiniteData<{
    page: CommentPage;
    pageParam: number;
}>>(usable: React.Usable<InfiniteData<{
    page: CommentPage;
    pageParam: number;
}>>): InfiniteData<{
    page: CommentPage;
    pageParam: number;
}>
use
(
const pagesPromise: Thenable<InfiniteData<{
    page: CommentPage;
    pageParam: number;
}>>
pagesPromise
);
const data: InfiniteData<{
    page: CommentPage;
    pageParam: number;
}>
return ( <> {
const data: InfiniteData<{
    page: CommentPage;
    pageParam: number;
}>
data
.InfiniteData<{ page: CommentPage; pageParam: number; }>.pages: readonly CommentPage[]pages
.ReadonlyArray<CommentPage>.flatMap<PostComment, undefined>(callback: (this: undefined, value: CommentPage, index: number, array: CommentPage[]) => PostComment | readonly PostComment[], thisArg?: undefined): PostComment[]flatMap((page: CommentPagepage) => page: CommentPagepage.CommentPage.items: readonly PostComment[]items) .Array<PostComment>.map<React.JSX.Element>(callbackfn: (value: PostComment, index: number, array: PostComment[]) => React.JSX.Element, thisArg?: any): React.JSX.Element[]map((comment: PostCommentcomment) => ( <
function Comment(props: {
    readonly author: string;
    readonly body: string;
}): React.ReactNode
Comment
React.Attributes.key?: React.Key | null | undefinedkey={comment: PostCommentcomment.PostComment.id: stringid} author: stringauthor={comment: PostCommentcomment.PostComment.author: stringauthor} body: stringbody={comment: PostCommentcomment.PostComment.body: stringbody} /> ))} <React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button React.ButtonHTMLAttributes<HTMLButtonElement>.disabled?: boolean | undefineddisabled={!
const comments: QueryControls<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>> & PagingControls
comments
.PagingControls.hasNextPage: booleanhasNextPage ||
const comments: QueryControls<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>> & PagingControls
comments
.PagingControls.isFetchingNextPage: booleanisFetchingNextPage}
React.DOMAttributes<HTMLButtonElement>.onClick?: React.MouseEventHandler<HTMLButtonElement> | undefinedonClick={() =>
const comments: QueryControls<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>> & PagingControls
comments
.PagingControls.fetchNextPage(): Promise<void>fetchNextPage()}
> Load more </React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button> </> ); }
ControlMeaning
fetchNextPage(), fetchPreviousPage()Load a page at either end; returns a promise
hasNextPage, hasPreviousPageWhether the page param functions found one
isFetchingNextPage, isFetchingPreviousPageA page is loading at that end

They exist only on the type for paged factories.

Infinite scroll

Infinite scroll is the same paged query with an automatic "Load more". A ref callback watches the button and presses it when it nears the viewport. Unmount the button while a page loads: it watches again when it remounts, so it keeps loading while it stays in reach. Its place is taken by skeletons the size of the incoming page, so the loaded rows replace them without moving anything below.

{
	
const comments: QueryControls<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>> & PagingControls
comments
.PagingControls.isFetchingNextPage: booleanisFetchingNextPage ? <function CommentSkeletons(): React.ReactNodeCommentSkeletons /> : null;
} {
const comments: QueryControls<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>> & PagingControls
comments
.PagingControls.hasNextPage: booleanhasNextPage && !
const comments: QueryControls<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>> & PagingControls
comments
.PagingControls.isFetchingNextPage: booleanisFetchingNextPage ? (
<React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button React.DOMAttributes<HTMLButtonElement>.onClick?: React.MouseEventHandler<HTMLButtonElement> | undefinedonClick={() =>
const comments: QueryControls<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>> & PagingControls
comments
.PagingControls.fetchNextPage(): Promise<void>fetchNextPage()}
React.RefAttributes<HTMLButtonElement>.ref?: React.Ref<HTMLButtonElement> | undefinedref={(button: HTMLButtonElement | nullbutton) => { if (button: HTMLButtonElement | nullbutton === null) return; const const observer: IntersectionObserverobserver = new var IntersectionObserver: new (callback: IntersectionObserverCallback, options?: IntersectionObserverInit) => IntersectionObserverIntersectionObserver( (entries: IntersectionObserverEntry[]entries) => { if (entries: IntersectionObserverEntry[]entries.Array<IntersectionObserverEntry>.some(predicate: (value: IntersectionObserverEntry, index: number, array: IntersectionObserverEntry[]) => unknown, thisArg?: any): booleansome((entry: IntersectionObserverEntryentry) => entry: IntersectionObserverEntryentry.IntersectionObserverEntry.isIntersecting: booleanisIntersecting)) { const observer: IntersectionObserverobserver.IntersectionObserver.disconnect(): voiddisconnect();
const comments: QueryControls<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>> & PagingControls
comments
.PagingControls.fetchNextPage(): Promise<void>fetchNextPage();
} }, { IntersectionObserverInit.rootMargin?: string | undefinedrootMargin: "0px 0px 300px 0px" }, ); const observer: IntersectionObserverobserver.IntersectionObserver.observe(target: Element): voidobserve(button: HTMLButtonElementbutton); return () => const observer: IntersectionObserverobserver.IntersectionObserver.disconnect(): voiddisconnect(); }} > Load more </React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button> ) : null; }

Scroll the feed below. Switch the filter to see a new list start from its first page, and switch back to find the old one still cached, with every page you loaded.

Loading several pages up front

On the server, ensure and fetch load the first page. Pass { pages: n } as a third argument to load more:

const 
const comments: QueryHandleWithData<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
comments
= await const qc: QueryClient<never>qc.
QueryClient<never>.ensure<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>(factory: (input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>, input: {
    slug: string;
}, options: LoadOptions): Promise<QueryHandleWithData<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>> (+1 overload)
ensure
(
function commentsQuery(input: {
    slug: string;
}): InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>
commentsQuery
, { slug: stringslug }, { LoadOptions.pages?: number | undefinedpages: 2 });

The option is only accepted for paged factories.

Refetching

When a paged query refetches — stale on navigation, invalidated, focused — every loaded page is refetched in order, starting from the first, so cursors stay consistent: page two's param is recomputed from the new page one.

Writing pages while one loads

setData on a paged query can land while fetchNextPage or fetchPreviousPage is in flight, for example from a live subscription updating a loaded page. The new page is added to the data as it is when the page arrives, so the write stays. If the write changed the end being loaded (it dropped or replaced the pages there), the page that arrives no longer follows the list and is dropped; load it again with another fetchNextPage.

A refetch reads every page again, and keeps writes that are newer than its reads. A page written after the refetch started reading it stays as written. A page written before that is read again, and the read replaces it. So a live subscription can keep writing pages while a refetch runs, and its results survive. If a write changed the list's page params, only pages whose param the refetch also loaded are kept.

Bidirectional lists

Add getPreviousPageParam to load pages before the first one, for a chat log that starts in the middle:

infiniteQueryOptions<MessagePage, string, unknown, never, readonly ["messages", string], undefined>(options: Omit<InfiniteQueryOptions<{
    error: unknown;
    key: readonly ["messages", string];
    page: MessagePage;
    pageParam: string;
    services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): InfiniteQueryOptions<Flat<{
    key: readonly ["messages", string];
    page: MessagePage;
    pageParam: string;
}>>
infiniteQueryOptions
({
queryKey: readonly ["messages", string]queryKey: ["messages",
const input: {
    channel: string;
    around: string;
}
input
.channel: stringchannel],
function queryFn(context: PageFunctionContext<string>): QueryFnResult<{
    data: MessagePage;
    error: unknown;
    services: never;
}>
queryFn
: (context: PageFunctionContext<string>context) =>
function listMessages(input: {
    readonly channel: string;
    readonly cursor: string;
}): Promise<MessagePage>
listMessages
({ channel: stringchannel:
const input: {
    channel: string;
    around: string;
}
input
.channel: stringchannel, cursor: stringcursor: context: PageFunctionContext<string>context.PageFunctionContext<string>.pageParam: stringpageParam }),
initialPageParam: stringinitialPageParam:
const input: {
    channel: string;
    around: string;
}
input
.around: stringaround,
function getNextPageParam(lastPage: MessagePage, pages: readonly MessagePage[], lastPageParam: string, pageParams: readonly string[]): string | null | undefinedgetNextPageParam: (lastPage: MessagePagelastPage) => lastPage: MessagePagelastPage.MessagePage.nextCursor: string | nullnextCursor, getPreviousPageParam?(firstPage: MessagePage, pages: readonly MessagePage[], firstPageParam: string, pageParams: readonly string[]): string | null | undefinedgetPreviousPageParam: (firstPage: MessagePagefirstPage) => firstPage: MessagePagefirstPage.MessagePage.previousCursor: string | nullpreviousCursor, });

context.direction tells the queryFn which end it's loading.

Limiting pages with maxPages

Long sessions can accumulate many pages. maxPages keeps at most that many: when a new page is added, the page at the far end is dropped. Refetches then load fewer pages, too. With maxPages, define both getNextPageParam and getPreviousPageParam, so a dropped page can be loaded again when the user scrolls back.

Changing the input

Pages belong to one input. A filter or sort in the URL changes the input, and with it the key, so a new list starts from its first page. The old list stays cached under its own key until gcTime, which is why switching back is instant.