createSuspenseInfiniteQuery
createSuspenseInfiniteQuery creates a Suspense-enabled infinite query object. Data is always defined — Suspense handles loading states.
Import
Section titled “Import”import { createSuspenseInfiniteQuery, createSuspenseInfiniteQueryFactory,} from "@ic-reactor/react"Basic Usage
Section titled “Basic Usage”import { createSuspenseInfiniteQuery } from "@ic-reactor/react"import { backend } from "./reactor"
const postsQuery = createSuspenseInfiniteQuery(backend, { functionName: "list_posts", initialPageParam: 0, getArgs: (offset) => [{ offset, limit: 20 }] as const, getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,})getArgs must return the method’s argument tuple. Without as const,
TypeScript infers the returned value as an array rather than a tuple and
reports TS2322. createInfiniteQuery
needs it for the same reason.
as const makes nested arrays readonly as well, so it fails when an argument
is an opt or a vec. For a method that takes (opt text, nat, nat),
[[], cursor, 10n] as const reports that readonly [] is not assignable to
[] | [string]. Annotate the return type for those methods instead:
import { createSuspenseInfiniteQuery, defineReactor } from "@ic-reactor/react"import { canisterId, idlFactory, type _SERVICE } from "./declarations/backend"
// A raw `Reactor`, so an `opt` is `[] | [string]` and a `nat` is a bigintconst { reactor: rawBackend } = defineReactor<_SERVICE>({ name: "backend", idlFactory, canisterId,})
const searchQuery = createSuspenseInfiniteQuery(rawBackend, { functionName: "search_posts", initialPageParam: 0n, getArgs: (cursor): [[] | [string], bigint, bigint] => [[], cursor, 10n], getNextPageParam: (lastPage, allPages) => lastPage.hasMore ? BigInt(allPages.length) * 10n : undefined,})The function returned by createSuspenseInfiniteQueryFactory already knows the
method, so the getArgs you pass to it needs neither.
Key Differences from createInfiniteQuery
Section titled “Key Differences from createInfiniteQuery”| Feature | createInfiniteQuery |
createSuspenseInfiniteQuery |
|---|---|---|
| Loading handling | Manual (isFetching) |
React Suspense |
data type |
InfiniteData | undefined |
InfiniteData (always) |
enabled option |
✅ Supported | ❌ Not available |
| Render blocking | No | Yes (suspends) |
Configuration
Section titled “Configuration”Same options as createInfiniteQuery:
| Option | Type | Description |
|---|---|---|
functionName |
string |
The canister method to call |
initialPageParam |
TPageParam |
Initial cursor/offset value |
getArgs |
(pageParam) => Args |
Convert page param to method args |
getNextPageParam |
function |
Determine next page param |
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 |
Return Value
Section titled “Return Value”| Property | Type | Description |
|---|---|---|
fetch |
() => Promise<T> |
Fetch first page (for loaders) |
useSuspenseInfiniteQuery |
hook |
Suspense-enabled 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; the hook suspends again |
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 useSuspenseInfiniteQuery(); outside React, call
invalidate(), or reset() to show the Suspense fallback and load from the
first page again. optimisticUpdate gets and returns the raw InfiniteData,
{ pages, pageParams }.
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 Suspense Streaming
Section titled “TanStack Router with Suspense Streaming”Prefetch and stream content:
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: "get_feed", initialPageParam: null as string | null, getArgs: (cursor) => [{ cursor, limit: 20 }] as const, 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> )}With Error Boundary
Section titled “With Error Boundary”Handle canister errors gracefully:
import { Suspense } from "react"import { ErrorBoundary } from "react-error-boundary"import { createSuspenseInfiniteQueryFactory } from "@ic-reactor/react"import { backend } from "./reactor"
const getCommentsQuery = createSuspenseInfiniteQueryFactory(backend, { functionName: "list_comments", initialPageParam: null as string | null, getNextPageParam: (lastPage) => lastPage.nextCursor,})
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((cursor) => [ { postId, cursor, limit: 20 }, ])
// 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> )}Infinite Scroll with Suspend
Section titled “Infinite Scroll with Suspend”Combined with IntersectionObserver:
import { Suspense, useEffect, useRef } from "react"import { createSuspenseInfiniteQuery } from "@ic-reactor/react"import { backend } from "./reactor"
const galleryQuery = createSuspenseInfiniteQuery(backend, { functionName: "list_images", initialPageParam: 0, getArgs: (offset) => [{ offset, limit: 24 }] as const, getNextPageParam: (lastPage) => lastPage.nextOffset,})
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 Suspensefunction GalleryPage() { return ( <Suspense fallback={<GallerySkeleton />}> <InfiniteGallery /> </Suspense> )}Select for Flattened Data
Section titled “Select for Flattened Data”Flatten pages at factory level:
import { createSuspenseInfiniteQuery } from "@ic-reactor/react"import { backend } from "./reactor"
const productListQuery = createSuspenseInfiniteQuery(backend, { functionName: "list_products", initialPageParam: 0, getArgs: (offset) => [{ offset, limit: 24 }] as const, 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> )}createSuspenseInfiniteQueryFactory
Section titled “createSuspenseInfiniteQueryFactory”For dynamic args generation:
import { Suspense } from "react"import { createSuspenseInfiniteQueryFactory } from "@ic-reactor/react"import { backend } from "./reactor"
const getCategoryProductsQuery = createSuspenseInfiniteQueryFactory(backend, { functionName: "list_products_by_category", initialPageParam: 0, // Optional: derive logical identity from first-page args getKeyArgs: (args) => { const [categoryId] = args return [categoryId] }, getNextPageParam: (lastPage) => lastPage.nextOffset,})
// Create with specific categoryfunction CategoryProducts({ categoryId }: { categoryId: string }) { const query = getCategoryProductsQuery((offset) => [ categoryId, { offset, limit: 20 }, ])
const { data } = query.useSuspenseInfiniteQuery()
return <ProductGrid products={data.pages.flatMap((p) => p.products)} />}
// The boundary goes above the component that suspends: one inside it would// never show its fallbackfunction CategoryPage({ categoryId }: { categoryId: string }) { return ( <Suspense fallback={<ProductGridSkeleton />}> <CategoryProducts categoryId={categoryId} /> </Suspense> )}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, per-call queryKey or number of
loaded pages:
import { createMutation, createSuspenseInfiniteQueryFactory,} from "@ic-reactor/react"import { backend } from "./reactor"
const getCategoryProductsQuery = createSuspenseInfiniteQueryFactory(backend, { functionName: "list_products_by_category", initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextOffset,})
// Refetch every category's product listasync function refreshCategories() { await getCategoryProductsQuery.invalidate()}
// Or after a mutationconst addProduct = createMutation(backend, { functionName: "add_product", invalidateQueries: [getCategoryProductsQuery],})Factory Cache Identity (Route/Search Params)
Section titled “Factory Cache Identity (Route/Search Params)”createSuspenseInfiniteQueryFactory(...) derives the query key from
getArgs(initialPageParam) by default. Use getKeyArgs in the factory config to
exclude pagination state and keep only the logical identity (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 { createSuspenseInfiniteQueryFactory } from "@ic-reactor/react"import { todoReactor } from "./reactor"
const todoListInfiniteQuery = createSuspenseInfiniteQueryFactory(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,})
const todoListQuery = todoListInfiniteQuery((offset: string) => [ { completed: undefined, search: "", sort_by: undefined, paginate: { offset, limit: 20 }, },])See Also
Section titled “See Also”- createInfiniteQuery — Non-Suspense version
- createSuspenseQuery — Non-paginated Suspense
- useActorSuspenseInfiniteQuery — Direct hook