Skip to content

createInfiniteQuery

createInfiniteQuery creates a reusable infinite query object for paginated data. Perfect for infinite scroll, “load more” buttons, and cursor-based pagination.

import {
createInfiniteQuery,
createInfiniteQueryFactory,
} from "@ic-reactor/react"
import { createInfiniteQuery } from "@ic-reactor/react"
import { backend } from "./reactor"
const postsQuery = createInfiniteQuery(backend, {
functionName: "getPosts",
initialPageParam: 0,
getArgs: (offset) => [{ offset, limit: 20 }] as const, // Type inference
getNextPageParam: (lastPage, allPages) =>
lastPage.posts.length < 20 ? undefined : allPages.length * 20,
})
OptionTypeDescription
functionNamestringThe canister method to call (Required)
initialPageParamTPageParamInitial cursor/offset value (Required)
getArgs(pageParam) => ArgsConvert page param to method args (Required)
getNextPageParamfunctionDetermine next page param (Required)
getPreviousPageParamfunctionFor bi-directional scrolling
maxPagesnumberMax pages to keep in cache
staleTimenumberTime before data is stale (ms)
selectfunctionTransform the InfiniteData result
callConfigCallConfigIC call configuration
// getNextPageParam signature
getNextPageParam: (
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 | null
PropertyTypeDescription
fetch() => Promise<T>Fetch first page (for loaders)
useInfiniteQuery(options?) => UseInfiniteQueryResultReact hook
refetch() => Promise<void>Invalidate and refetch all pages
getQueryKey() => QueryKeyGet the query key
getCacheData(select?) => TRead from cache

Combine with TanStack Virtual for performant infinite scroll:

routes/posts.tsx
import { createFileRoute } from "@tanstack/react-router"
import { useVirtualizer } from "@tanstack/react-virtual"
import { createInfiniteQuery } from "@ic-reactor/react"
import { backend } from "../reactor"
import { useRef, useCallback } from "react"
const postsQuery = createInfiniteQuery(backend, {
functionName: "getPosts",
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>
)
}

Use cursors instead of offsets:

interface PostsResponse {
posts: Post[]
nextCursor: string | null
}
const postsQuery = createInfiniteQuery(backend, {
functionName: "getPosts",
initialPageParam: null as string | null,
getArgs: (cursor) => [{ cursor, limit: 20 }] as const,
getNextPageParam: (lastPage: PostsResponse) => lastPage.nextCursor,
})

Load pages in both directions (like a chat):

const messagesQuery = createInfiniteQuery(backend, {
functionName: "getMessages",
initialPageParam: { timestamp: 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>
)
}

Simple load more without infinite scroll:

function ProductList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = productsQuery.useInfiniteQuery()
const products = data?.pages.flatMap((page) => page.items) ?? []
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>
)
}

Flatten pages automatically:

const postsQuery = createInfiniteQuery(backend, {
functionName: "getPosts",
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()
return (
<div>
<span>{data.pageCount} pages loaded</span>
{data.posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
)
}

Use IntersectionObserver for automatic loading:

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.items).map((item) => (
<ItemCard key={item.id} item={item} />
))}
<div ref={loadMoreRef} style={{ height: 20 }}>
{isFetchingNextPage && <Spinner />}
</div>
</div>
)
}

For dynamic getArgs functions:

const getPostsQuery = createInfiniteQueryFactory(backend, {
functionName: "getPosts",
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextOffset,
})
// Create with specific args builder
const userPostsQuery = getPostsQuery((offset) => [
{
userId,
offset,
limit: 20,
},
])
function UserPosts({ userId }: { userId: string }) {
const { data } = userPostsQuery.useInfiniteQuery()
// ...
}