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(...), and components read it with useInfiniteQuery. Loaders and verbs take it like any query.

src/queries.ts
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, }); }
  • comments is a group: it builds every comments key.
  • initialPageParam is the first page's param.
  • getNextPageParam(lastPage, pages, lastPageParam) returns the next page's param, or null / undefined when there are no more pages.
  • The queryFn receives the param as context.pageParam, next to signal.

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
);
const data: InfiniteData<CommentPage, number>
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.

@paramcallback — A function that accepts up to three arguments. The flatMap method calls the callback function one time for each element in the array.@paramthisArg — An object to which the this keyword can refer in the callback function. If thisArg is omitted, undefined is used as the this value.
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.

@paramcallbackfn — A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.@paramthisArg — An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
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> </> ); }
ControlMeaning
fetchNextPage(), fetchPreviousPage()Load a page at either end; resolves when it's there
hasNextPage, hasPreviousPageWhether the page param functions found one
isFetchingNextPage, isFetchingPreviousPageA 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> | undefined

Allows 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) => IntersectionObserver

The 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): boolean

Determines whether the specified callback function returns true for any element of an array.

@parampredicate — A function that accepts up to three arguments. The some method calls the predicate function for each element in the array until the predicate returns a value which is coercible to the Boolean value true, or until the end of the array.@paramthisArg — An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
some
((entry: IntersectionObserverEntryentry) => entry: IntersectionObserverEntryentry.IntersectionObserverEntry.isIntersecting: boolean

The 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(): void

The 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): void

The 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(): void

The 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 | undefined

For 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: AbortSignal

Aborted 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.