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(...), and components read it with useInfiniteQuery. Loaders and verbs take it like any query.
export function function commentsQuery(input: {
slug: string;
}): InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>
commentsQuery(input: {
slug: string;
}
input: { slug: stringslug: string }) {
return infiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>>(options: InfiniteOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes> & {
readonly queryFn: (context: InfiniteQueryFunctionContext<readonly ["posts", "comments", string], number>) => CommentPage | PromiseLike<CommentPage>;
}): InfiniteQueryOptions<...>
Typed options for a paged query factory. Readers get { pages, pageParams }
and, on their state, fetchNextPage and fetchPreviousPage:
infiniteQueryOptions({
BaseOptions<readonly ["posts", "comments", string], DefaultTypes>.queryKey: readonly ["posts", "comments", string]A plain array, usually built by a group(). Handles keep its literal type.
queryKey: comments<readonly [string]>(rest_0: string): readonly ["posts", "comments", string]comments(input: {
slug: string;
}
input.slug: stringslug),
queryFn: (context: InfiniteQueryFunctionContext<readonly ["posts", "comments", string], number>) => CommentPage | PromiseLike<CommentPage>queryFn: (context: InfiniteQueryFunctionContext<readonly ["posts", "comments", string], number>context) =>
function listComments(input: {
cursor: number;
slug: string;
}): Promise<CommentPage>
listComments({ slug: stringslug: input: {
slug: string;
}
input.slug: stringslug, cursor: numbercursor: context: InfiniteQueryFunctionContext<readonly ["posts", "comments", string], number>context.InfiniteQueryFunctionContext<readonly ["posts", "comments", string], number>.pageParam: numberpageParam }),
InfiniteOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>.initialPageParam: numberinitialPageParam: 0,
InfiniteOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>.getNextPageParam: PageParamFn<CommentPage, number>getNextPageParam: (lastPage: CommentPagelastPage) => lastPage: CommentPagelastPage.CommentPage.nextCursor: number | nullnextCursor,
});
}
commentsis a group: it builds every comments key.initialPageParamis the first page's param.getNextPageParam(lastPage, pages, lastPageParam)returns the next page's param, ornull/undefinedwhen there are no more pages.- The
queryFnreceives the param ascontext.pageParam, next tosignal.
Reading pages
useInfiniteQuery(factory, handle, options?) returns the same [thenable, state] tuple as useQuery, with the same options. The data is { pages, pageParams }, and the state adds 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<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>
handle: interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => { readonly queryFn: (context: never) => unknown; readonly queryKey: QueryKey; }>A small serializable reference to a query: its key and its factory's
input. It carries no data; readers get that from the cache. Handles of
different factories have different types.
QueryHandle<typeof function commentsQuery(input: {
slug: string;
}): InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>
commentsQuery> }) {
const [const pagesThenable: QueryValue<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>
pagesThenable, const comments: InfiniteQueryState<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>
comments] = useInfiniteQuery<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>(factory: (input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, handle: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>, options?: UseQueryOptions): readonly [...]
Read a paged query (infiniteQueryOptions) by its handle. The data is
{ pages, pageParams }, and its state loads more pages.
useInfiniteQuery(
function commentsQuery(input: {
slug: string;
}): InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>
commentsQuery,
props: {
handle: QueryHandle<typeof commentsQuery>;
}
props.handle: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>
handle,
);
const data = use<InfiniteData<CommentPage, number>>(usable: React.Usable<InfiniteData<CommentPage, number>>): InfiniteData<CommentPage, number>use(const pagesThenable: QueryValue<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>
pagesThenable);
return (
<>
{const data: InfiniteData<CommentPage, number>data.InfiniteData<CommentPage, 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[]Calls a defined callback function on each element of an array. Then, flattens the result into
a new array.
This is identical to a map followed by flat with depth 1.
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[]Calls a defined callback function on each element of an array, and returns an array that contains the results.
map((comment: PostCommentcomment) => (
<function Comment(props: {
readonly author: string;
readonly body: string;
}): 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: InfiniteQueryState<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>
comments.PageControls.hasNextPage: booleanhasNextPage || const comments: InfiniteQueryState<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>
comments.PageControls.isFetchingNextPage: booleanisFetchingNextPage}
React.DOMAttributes<HTMLButtonElement>.onClick?: React.MouseEventHandler<HTMLButtonElement> | undefinedonClick={() => const comments: InfiniteQueryState<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>
comments.PageControls.fetchNextPage(): Promise<void>Load the page after the last one. Resolves when it's there.
fetchNextPage()}
>
Load more
</React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button>
</>
);
}
| Control | Meaning |
|---|---|
fetchNextPage(), fetchPreviousPage() | Load a page at either end; resolves when it's there |
hasNextPage, hasPreviousPage | Whether the page param functions found one |
isFetchingNextPage, isFetchingPreviousPage | A page is loading at that end |
useInfiniteQuery takes only paged factories, and useQuery only factories that aren't: passing the other kind is a type error. The paging code is in useInfiniteQuery alone, so an app that never pages doesn't ship it.
Infinite scroll
Infinite scroll is the same paged query with an automatic "Load more". A ref callback watches the button with an IntersectionObserver 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: InfiniteQueryState<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>
comments.PageControls.isFetchingNextPage: booleanisFetchingNextPage ? <function CommentSkeletons(): ReactNodeCommentSkeletons /> : null;
}
{
const comments: InfiniteQueryState<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>
comments.PageControls.hasNextPage: booleanhasNextPage && !const comments: InfiniteQueryState<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>
comments.PageControls.isFetchingNextPage: booleanisFetchingNextPage ? (
<React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button
React.DOMAttributes<HTMLButtonElement>.onClick?: React.MouseEventHandler<HTMLButtonElement> | undefinedonClick={() => const comments: InfiniteQueryState<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>
comments.PageControls.fetchNextPage(): Promise<void>Load the page after the last one. Resolves when it's there.
fetchNextPage()}
React.RefAttributes<HTMLButtonElement>.ref?: React.Ref<HTMLButtonElement> | undefinedAllows getting a ref to the component instance.
Once the component unmounts, React will set ref.current to null
(or call the ref with null if you passed a callback ref).
ref={(button: HTMLButtonElement | nullbutton) => {
if (button: HTMLButtonElement | nullbutton === null) return;
const const observer: IntersectionObserverobserver = new var IntersectionObserver: new (callback: IntersectionObserverCallback, options?: IntersectionObserverInit) => IntersectionObserverThe IntersectionObserver interface of the Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport. The ancestor element or viewport is referred to as the root.
IntersectionObserver(
(entries: IntersectionObserverEntry[]entries) => {
if (entries: IntersectionObserverEntry[]entries.Array<IntersectionObserverEntry>.some(predicate: (value: IntersectionObserverEntry, index: number, array: IntersectionObserverEntry[]) => unknown, thisArg?: any): booleanDetermines whether the specified callback function returns true for any element of an array.
some((entry: IntersectionObserverEntryentry) => entry: IntersectionObserverEntryentry.IntersectionObserverEntry.isIntersecting: booleanThe isIntersecting read-only property of the IntersectionObserverEntry interface is a Boolean value which is true if the target element intersects with the intersection observer's root.
isIntersecting)) {
const observer: IntersectionObserverobserver.IntersectionObserver.disconnect(): voidThe disconnect() method of the IntersectionObserver interface stops the observer watching all of its target elements for visibility changes.
disconnect();
void const comments: InfiniteQueryState<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>
comments.PageControls.fetchNextPage(): Promise<void>Load the page after the last one. Resolves when it's there.
fetchNextPage();
}
},
{ IntersectionObserverInit.rootMargin?: string | undefinedrootMargin: "0px 0px 300px 0px" },
);
const observer: IntersectionObserverobserver.IntersectionObserver.observe(target: Element): voidThe observe() method of the IntersectionObserver interface adds an element to the set of target elements being watched by the IntersectionObserver. One observer has one set of thresholds and one root, but can watch multiple target elements for visibility changes in keeping with those.
observe(button: HTMLButtonElementbutton);
return () => const observer: IntersectionObserverobserver.IntersectionObserver.disconnect(): voidThe disconnect() method of the IntersectionObserver interface stops the observer watching all of its target elements for visibility changes.
disconnect();
}}
>
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
A loader's swr and revalidate load the first page. Pass { pages: n } after the input to load more:
const [const handles: {
readonly comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>;
}
handles] = await load<{
readonly comments: LoadEntry<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "swr">;
}>(client: QueryClient, entries: {
readonly comments: LoadEntry<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "swr">;
}): Promise<...>
Start a loader's queries and return a serializable handle for each. It
starts every entry before it waits for any, so the order of the entries
can't create a waterfall. Resolves with [handles, data] once every
swr and revalidate entry has data, and rejects with the error as soon
as one fails.
prefetch entries never make it reject. A skipToken in place of an
entry starts nothing and gets no handle.
load(const queryClient: QueryClient<any>queryClient, {
comments: LoadEntry<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "swr">
comments: swr<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>(factory: (input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, ...args: LoadEntryArgs<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>): LoadEntry<...>
Use cached data even when it's stale, and wait only when the cache has
none. Stale data resolves at once and refetches in the background, as
HTTP's
stale-while-revalidate
does. The load rejects if the fetch it waits for fails.
swr(function commentsQuery(input: {
slug: string;
}): InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>
commentsQuery, { slug: stringslug }, { LoadEntryOptions.pages?: number | undefinedFor paged factories: how many pages a first load fetches. Default 1.
pages: 2 }),
});The option only changes a first load: a query that already has pages keeps the ones it has.
Refetching
When a paged query refetches (invalidated, refetched on focus, stale in a loader), 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. A refetch stops early if getNextPageParam finds no more pages.
Loading a page starts from the data as it was when the request started, and replaces the data when the page arrives.
Bidirectional lists
Add getPreviousPageParam(firstPage, pages, firstPageParam) to load pages before the first one, for a chat log that starts in the middle:
infiniteQueryOptions<readonly ["messages", string], MessagePage, string, InfiniteData<MessagePage, string>>(options: InfiniteOptions<readonly ["messages", string], MessagePage, string, InfiniteData<MessagePage, string>, DefaultTypes> & {
readonly queryFn: (context: InfiniteQueryFunctionContext<readonly ["messages", string], string>) => MessagePage | PromiseLike<MessagePage>;
}): InfiniteQueryOptions<...>
Typed options for a paged query factory. Readers get { pages, pageParams }
and, on their state, fetchNextPage and fetchPreviousPage:
infiniteQueryOptions({
BaseOptions<readonly ["messages", string], DefaultTypes>.queryKey: readonly ["messages", string]A plain array, usually built by a group(). Handles keep its literal type.
queryKey: const messages: Group
<readonly [string]>(rest_0: string) => readonly ["messages", string]
messages(const input: {
channel: string;
around: string;
}
input.channel: stringchannel),
queryFn: (context: InfiniteQueryFunctionContext<readonly ["messages", string], string>) => MessagePage | PromiseLike<MessagePage>queryFn: (context: InfiniteQueryFunctionContext<readonly ["messages", string], string>context) =>
function listMessages(input: {
readonly channel: string;
readonly cursor: string;
readonly signal: AbortSignal;
}): Promise<MessagePage>
listMessages({
channel: stringchannel: const input: {
channel: string;
around: string;
}
input.channel: stringchannel,
cursor: stringcursor: context: InfiniteQueryFunctionContext<readonly ["messages", string], string>context.InfiniteQueryFunctionContext<readonly ["messages", string], string>.pageParam: stringpageParam,
signal: AbortSignalsignal: context: InfiniteQueryFunctionContext<readonly ["messages", string], string>context.QueryFunctionContext<readonly ["messages", string]>.signal: AbortSignalAborted when the request is restarted or its entry is removed.
signal,
}),
InfiniteOptions<readonly ["messages", string], MessagePage, string, InfiniteData<MessagePage, string>, DefaultTypes>.initialPageParam: stringinitialPageParam: const input: {
channel: string;
around: string;
}
input.around: stringaround,
InfiniteOptions<readonly ["messages", string], MessagePage, string, InfiniteData<MessagePage, string>, DefaultTypes>.getNextPageParam: PageParamFn<MessagePage, string>getNextPageParam: (lastPage: MessagePagelastPage) => lastPage: MessagePagelastPage.MessagePage.nextCursor: string | nullnextCursor,
InfiniteOptions<readonly ["messages", string], MessagePage, string, InfiniteData<MessagePage, string>, DefaultTypes>.getPreviousPageParam?: PageParamFn<MessagePage, string> | undefinedgetPreviousPageParam: (firstPage: MessagePagefirstPage) => firstPage: MessagePagefirstPage.MessagePage.previousCursor: string | nullpreviousCursor,
});context.direction tells the queryFn which way it's loading: "backward" for a page before the first, "forward" otherwise.
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. See URL state and pagination.