createInfiniteQuery
createInfiniteQuery creates a reusable infinite query object for paginated data. Perfect for infinite scroll, “load more” buttons, and cursor-based pagination.
Import
Section titled “Import”import { createInfiniteQuery, createInfiniteQueryFactory,} from "@ic-reactor/react"Basic Usage
Section titled “Basic Usage”import { createInfiniteQuery } from "@ic-reactor/react"import { backend } from "./reactor"
const postsQuery = createInfiniteQuery(backend, { functionName: "list_posts", initialPageParam: 0, getArgs: (offset) => [{ offset, limit: 20 }] as const, // Type inference getNextPageParam: (lastPage, allPages) => lastPage.posts.length < 20 ? undefined : allPages.length * 20,})Configuration
Section titled “Configuration”createInfiniteQuery accepts standard TanStack Query infinite-query options at
the create level (for example refetchInterval, refetchOnMount,
refetchOnWindowFocus, retry, gcTime, and networkMode) in addition to the
IC Reactor-specific options below. networkMode, retry, retryDelay and
meta also apply to fetch(); the rest apply to the hook only.
| Option | Type | Description |
|---|---|---|
functionName |
string |
The canister method to call (Required) |
initialPageParam |
TPageParam |
Initial cursor/offset value (Required) |
getArgs |
(pageParam) => Args |
Convert page param to method args (Required) |
getNextPageParam |
function |
Determine next page param (Required) |
getPreviousPageParam |
function |
For bi-directional scrolling |
maxPages |
number |
Max pages to keep in cache |
staleTime |
number |
Time before data is stale (ms) |
select |
function |
Transform the InfiniteData result |
callConfig |
CallConfig |
IC call configuration |
Pagination Functions
Section titled “Pagination Functions”// getNextPageParam signaturegetNextPageParam: ( lastPage: PageData, // Last fetched page allPages: PageData[], // All pages so far lastPageParam: Param, // Page param used for last fetch allPageParams: Param[] // All page params used) => Param | undefined | null
// getPreviousPageParam signature (for bi-directional)getPreviousPageParam: ( firstPage: PageData, allPages: PageData[], firstPageParam: Param, allPageParams: Param[]) => Param | undefined | nullReturn Value
Section titled “Return Value”| Property | Type | Description |
|---|---|---|
fetch |
() => Promise<T> |
Fetch first page (for loaders) |
useInfiniteQuery |
(options?) => UseInfiniteQueryResult |
React hook |
invalidate |
() => Promise<void> |
Invalidate the cache (refetches if active) |
getQueryKey |
() => QueryKey |
Get the query key |
getCacheData |
(select?) => T | undefined |
Read from cache without fetching |
cancel |
() => Promise<void> |
Cancel the fetch in flight |
reset |
() => Promise<void> |
Reset to initial state; mounted hooks refetch |
optimisticUpdate |
(updater) => Promise<{ rollback }> |
Cancel, write updater(cached pages), return a rollback |
These eight keys are the whole object. To force a refetch from a component, use
the refetch returned by useInfiniteQuery(); outside React, call
invalidate(), or reset() to drop the loaded pages and start again from the
first one.
optimisticUpdate gets and returns the raw InfiniteData,
{ pages, pageParams }, before select. For example, to show a new post at
the top of the first page while it is being created:
import { createMutation } from "@ic-reactor/react"import { backend } from "./reactor"
const createPost = createMutation(backend, { functionName: "create_post", onMutate: () => postsQuery.optimisticUpdate((data) => ({ ...data, pages: [ { ...data.pages[0], posts: [newPost, ...data.pages[0].posts] }, ...data.pages.slice(1), ], })), onError: (_error, _args, draft) => draft?.rollback(),})A sign-in or sign-out while fetch() is in flight does not reject it: the
previous identity’s first page is dropped, and fetch() runs again for the new
identity and resolves with that page.
Examples
Section titled “Examples”TanStack Router with Virtual Scrolling
Section titled “TanStack Router with Virtual Scrolling”Combine with TanStack Virtual for performant infinite scroll:
import { createFileRoute } from "@tanstack/react-router"import { useVirtualizer } from "@tanstack/react-virtual"import { createInfiniteQuery } from "@ic-reactor/react"import { backend } from "../reactor"import { useEffect, useRef } from "react"
const postsQuery = createInfiniteQuery(backend, { functionName: "list_posts", initialPageParam: 0, getArgs: (offset) => [{ offset, limit: 20 }] as const, getNextPageParam: (lastPage, allPages) => lastPage.hasMore ? allPages.length * 20 : undefined,})
export const Route = createFileRoute("/posts")({ // Prefetch first page loader: async () => { await postsQuery.fetch() }, component: PostsPage,})
function PostsPage() { const parentRef = useRef<HTMLDivElement>(null)
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = postsQuery.useInfiniteQuery()
// Flatten all pages const allPosts = data?.pages.flatMap((page) => page.posts) ?? []
const virtualizer = useVirtualizer({ count: hasNextPage ? allPosts.length + 1 : allPosts.length, getScrollElement: () => parentRef.current, estimateSize: () => 100, overscan: 5, })
const items = virtualizer.getVirtualItems()
// Load more when reaching end const lastItem = items[items.length - 1] useEffect(() => { if ( lastItem?.index >= allPosts.length - 1 && hasNextPage && !isFetchingNextPage ) { fetchNextPage() } }, [lastItem?.index, hasNextPage, isFetchingNextPage, fetchNextPage])
return ( <div ref={parentRef} style={{ height: "100vh", overflow: "auto" }}> <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}> {items.map((virtualRow) => ( <div key={virtualRow.key} style={{ position: "absolute", top: virtualRow.start, width: "100%", }} > {virtualRow.index < allPosts.length ? ( <PostCard post={allPosts[virtualRow.index]} /> ) : ( <LoadingSpinner /> )} </div> ))} </div> </div> )}Cursor-Based Pagination
Section titled “Cursor-Based Pagination”Use cursors instead of offsets:
import { createInfiniteQuery } from "@ic-reactor/react"import { backend } from "./reactor"
const postsQuery = createInfiniteQuery(backend, { functionName: "list_posts_by_cursor", initialPageParam: null as string | null, getArgs: (cursor) => [{ cursor, limit: 20 }] as const, getNextPageParam: (lastPage) => lastPage.nextCursor,})Bi-directional Scrolling
Section titled “Bi-directional Scrolling”Load pages in both directions (like a chat):
import { createInfiniteQuery } from "@ic-reactor/react"import { backend } from "./reactor"
const messagesQuery = createInfiniteQuery(backend, { functionName: "get_messages", // A nat64 is text in a DisplayReactor initialPageParam: { timestamp: String(Date.now()) },
getArgs: (param) => [ { before: param.timestamp, limit: 50, }, ] as const,
// Load newer messages getNextPageParam: (lastPage) => lastPage.hasNewer ? { timestamp: lastPage.newestTimestamp } : undefined,
// Load older messages getPreviousPageParam: (firstPage) => firstPage.hasOlder ? { timestamp: firstPage.oldestTimestamp } : undefined,})
function ChatMessages() { const { data, fetchNextPage, fetchPreviousPage, hasPreviousPage, hasNextPage, isFetchingNextPage, isFetchingPreviousPage, } = messagesQuery.useInfiniteQuery()
return ( <div className="chat"> {hasPreviousPage && ( <button onClick={() => fetchPreviousPage()} disabled={isFetchingPreviousPage} > Load older messages </button> )}
{data?.pages .flatMap((page) => page.messages) .map((msg) => ( <Message key={msg.id} message={msg} /> ))}
{hasNextPage && ( <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}> Load newer messages </button> )} </div> )}“Load More” Button Pattern
Section titled ““Load More” Button Pattern”Simple load more without infinite scroll:
import { createInfiniteQuery } from "@ic-reactor/react"import { backend } from "./reactor"
const productsQuery = createInfiniteQuery(backend, { functionName: "list_products", initialPageParam: 0, getArgs: (offset) => [{ offset, limit: 24 }] as const, getNextPageParam: (lastPage) => lastPage.nextOffset,})
function ProductList() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = productsQuery.useInfiniteQuery()
const products = data?.pages.flatMap((page) => page.products) ?? []
return ( <div> <div className="product-grid"> {products.map((product) => ( <ProductCard key={product.id} product={product} /> ))} </div>
{hasNextPage && ( <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage} className="load-more" > {isFetchingNextPage ? ( <> <Spinner /> Loading... </> ) : ( `Load More (${products.length} items)` )} </button> )}
{!hasNextPage && products.length > 0 && ( <p className="end-message">You've reached the end!</p> )} </div> )}With Select Transformation
Section titled “With Select Transformation”Flatten pages automatically:
import { createInfiniteQuery } from "@ic-reactor/react"import { backend } from "./reactor"
const postsQuery = createInfiniteQuery(backend, { functionName: "list_posts", initialPageParam: 0, getArgs: (offset) => [{ offset, limit: 20 }] as const, getNextPageParam: (lastPage) => lastPage.nextOffset,
// Flatten in the factory select: (data) => ({ posts: data.pages.flatMap((page) => page.posts), pageCount: data.pages.length, }),})
function PostList() { // data is already flattened const { data } = postsQuery.useInfiniteQuery()
if (!data) return <div>Loading...</div>
return ( <div> <span>{data.pageCount} pages loaded</span> {data.posts.map((post) => ( <PostCard key={post.id} post={post} /> ))} </div> )}Intersection Observer for Auto-Load
Section titled “Intersection Observer for Auto-Load”Use IntersectionObserver for automatic loading:
import { useEffect, useRef } from "react"
function InfiniteList() { const loadMoreRef = useRef<HTMLDivElement>(null)
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = postsQuery.useInfiniteQuery()
useEffect(() => { const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { fetchNextPage() } }, { threshold: 0.1 } )
if (loadMoreRef.current) { observer.observe(loadMoreRef.current) }
return () => observer.disconnect() }, [hasNextPage, isFetchingNextPage, fetchNextPage])
return ( <div> {data?.pages .flatMap((page) => page.posts) .map((post) => ( <PostCard key={post.id} post={post} /> ))}
<div ref={loadMoreRef} style={{ height: 20 }}> {isFetchingNextPage && <Spinner />} </div> </div> )}createInfiniteQueryFactory
Section titled “createInfiniteQueryFactory”For dynamic getArgs functions:
import { createInfiniteQueryFactory } from "@ic-reactor/react"import { backend } from "./reactor"
const getPostsQuery = createInfiniteQueryFactory(backend, { functionName: "list_posts", initialPageParam: 0, // Optional: derive logical identity from first-page args (exclude cursor/page state) getKeyArgs: (args) => { const [request] = args return [{ userId: request.userId, filter: request.filter, q: request.q }] }, getNextPageParam: (lastPage) => lastPage.nextOffset,})
// Create with specific args builderconst userPostsQuery = getPostsQuery((offset) => [ { userId, offset, limit: 20, },])
function UserPosts({ userId }: { userId: string }) { const { data } = userPostsQuery.useInfiniteQuery() // ...}Every List at Once
Section titled “Every List at Once”The factory function has getQueryKey() and invalidate(), which cover every
list it returns, whatever its getArgs and however many pages it loaded:
import { createInfiniteQueryFactory, createMutation } from "@ic-reactor/react"import { backend } from "./reactor"
const getPostsQuery = createInfiniteQueryFactory(backend, { functionName: "list_posts", initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextOffset,})
// Refetch every loaded listasync function refreshLists() { await getPostsQuery.invalidate()}
// Or after a mutationconst createPost = createMutation(backend, { functionName: "create_post", invalidateQueries: [getPostsQuery],})The prefix is built from the method, the factory’s callConfig and its config
queryKey, the part of the key every instance shares.
Factory Cache Identity (Route/Search Params)
Section titled “Factory Cache Identity (Route/Search Params)”By default, createInfiniteQueryFactory(...) derives the query key from the
args returned by getArgs(initialPageParam). This is safer than a static key and
prevents many cache collisions automatically.
Use getKeyArgs in the factory config when your first-page args include
pagination state (like offset, cursor, or nested paginate) and you want
the cache identity to include only logical filters/search params.
The todo canister’s list_todos takes a request with two optional filters and
a paginate record, and returns a page with an optional next_offset. The
reactor below is the DisplayReactor the example builds on, so a nat is text
and an opt is T | undefined:
import { defineDisplayReactor } from "@ic-reactor/react"import { canisterId, idlFactory, type _SERVICE as TodoService,} from "./declarations/todo"
// list_todos : (record {// completed : opt bool;// search : text;// sort_by : opt variant { Title; CreatedAt };// paginate : record { offset : nat; limit : nat32 };// }) -> (record { todos : vec Todo; next_offset : opt nat }) queryexport const { reactor: todoReactor } = defineDisplayReactor<TodoService>({ name: "todo", idlFactory, canisterId,})import { createInfiniteQueryFactory } from "@ic-reactor/react"import { todoReactor } from "./reactor"
const todoListInfiniteQuery = createInfiniteQueryFactory(todoReactor, { functionName: "list_todos", initialPageParam: "0", getKeyArgs: (args) => { const [request] = args return [ { completed: request.completed, search: request.search, sort_by: request.sort_by, }, ] }, getNextPageParam: (lastPage) => lastPage.next_offset,})
// Query initiation stays unchangedconst todoListQuery = todoListInfiniteQuery((offset: string) => [ { completed: undefined, search: "", sort_by: undefined, paginate: { offset, limit: 20 }, },])See Also
Section titled “See Also”- createSuspenseInfiniteQuery — Suspense version
- useActorInfiniteQuery — Direct hook usage
- createQuery — Non-paginated queries