Skip to content

createSuspenseInfiniteQuery

createSuspenseInfiniteQuery creates a Suspense-enabled infinite query object. Data is always defined — Suspense handles loading states.

import {
createSuspenseInfiniteQuery,
createSuspenseInfiniteQueryFactory,
} from "@ic-reactor/react"
import { createSuspenseInfiniteQuery } from "@ic-reactor/react"
import { backend } from "./reactor"
const postsQuery = createSuspenseInfiniteQuery(backend, {
functionName: "getPosts",
initialPageParam: 0,
getArgs: (offset) => [{ offset, limit: 20 }],
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.nextOffset : undefined,
})
FeaturecreateInfiniteQuerycreateSuspenseInfiniteQuery
Loading handlingManual (isFetching)React Suspense
data typeInfiniteData | undefinedInfiniteData (always)
enabled option✅ Supported❌ Not available
Render blockingNoYes (suspends)

Same options as createInfiniteQuery:

OptionTypeDescription
functionNamestringThe canister method to call
initialPageParamTPageParamInitial cursor/offset value
getArgs(pageParam) => ArgsConvert page param to method args
getNextPageParamfunctionDetermine next page param
getPreviousPageParamfunctionFor bi-directional scrolling
maxPagesnumberMax pages to keep in cache
staleTimenumberTime before data is stale (ms)
selectfunctionTransform the InfiniteData result
PropertyTypeDescription
fetch() => Promise<T>Fetch first page (for loaders)
useSuspenseInfiniteQueryhookSuspense-enabled React hook
refetch() => Promise<void>Invalidate and refetch
getQueryKey() => QueryKeyGet the query key
getCacheData(select?) => TRead from cache

Prefetch and stream content:

routes/feed.tsx
import { createFileRoute } from "@tanstack/react-router"
import { Suspense } from "react"
import { createSuspenseInfiniteQuery } from "@ic-reactor/react"
import { backend } from "../reactor"
const feedQuery = createSuspenseInfiniteQuery(backend, {
functionName: "getFeed",
initialPageParam: null as string | null,
getArgs: (cursor) => [{ cursor, limit: 20 }],
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
function FeedPage() {
return (
<Suspense fallback={<FeedSkeleton />}>
<Feed />
</Suspense>
)
}
function Feed() {
// data is NEVER undefined - Suspense handles initial loading
const {
data: posts,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = feedQuery.useSuspenseInfiniteQuery({
select: (data) => data.pages.flatMap((page) => page.posts),
})
return (
<div className="feed">
{posts.map((post) => (
<FeedPost key={post.id} post={post} />
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? "Loading..." : "Load More"}
</button>
)}
</div>
)
}

Handle canister errors gracefully:

import { ErrorBoundary } from "react-error-boundary"
function CommentsSection({ postId }: { postId: string }) {
return (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<div className="error-panel">
<p>Failed to load comments</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
)}
>
<Suspense fallback={<CommentsSkeleton />}>
<CommentsList postId={postId} />
</Suspense>
</ErrorBoundary>
)
}
function CommentsList({ postId }: { postId: string }) {
const commentsQuery = getCommentsQuery(postId)
// data is always defined
const { data, fetchNextPage, hasNextPage } = commentsQuery.useSuspenseInfiniteQuery()
return (
<div>
{data.pages.flatMap((p) => p.comments).map((comment) => (
<Comment key={comment.id} comment={comment} />
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>
Load more comments
</button>
)}
</div>
)
}

Combined with IntersectionObserver:

function InfiniteGallery() {
const sentinelRef = useRef<HTMLDivElement>(null)
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = galleryQuery.useSuspenseInfiniteQuery()
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
},
{ threshold: 0.5 }
)
if (sentinelRef.current) {
observer.observe(sentinelRef.current)
}
return () => observer.disconnect()
}, [hasNextPage, isFetchingNextPage])
const images = data.pages.flatMap((page) => page.images)
return (
<div className="gallery-grid">
{images.map((image) => (
<GalleryImage key={image.id} image={image} />
))}
<div ref={sentinelRef} className="sentinel">
{isFetchingNextPage && <Spinner />}
</div>
</div>
)
}
// Wrap in Suspense
function GalleryPage() {
return (
<Suspense fallback={<GallerySkeleton />}>
<InfiniteGallery />
</Suspense>
)
}

Flatten pages at factory level:

const productListQuery = createSuspenseInfiniteQuery(backend, {
functionName: "getProducts",
initialPageParam: 0,
getArgs: (offset) => [{ offset, limit: 24 }],
getNextPageParam: (lastPage) => lastPage.nextOffset,
// Pre-flatten the data
select: (infiniteData) => ({
products: infiniteData.pages.flatMap((page) => page.products),
totalCount: infiniteData.pages[0]?.totalCount ?? 0,
hasMore: !!infiniteData.pageParams.at(-1),
}),
})
function ProductGrid() {
// Already flattened and typed
const { data } = productListQuery.useSuspenseInfiniteQuery()
return (
<div>
<h2>{data.totalCount} Products</h2>
<div className="grid">
{data.products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
)
}

For dynamic args generation:

const getCategoryProductsQuery = createSuspenseInfiniteQueryFactory(backend, {
functionName: "getProductsByCategory",
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextOffset,
})
// Create with specific category
function CategoryPage({ categoryId }: { categoryId: string }) {
const query = getCategoryProductsQuery((offset) => [
categoryId,
{ offset, limit: 20 }
])
const { data } = query.useInfiniteQuery()
return (
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid products={data.pages.flatMap((p) => p.products)} />
</Suspense>
)
}