# createSuspenseInfiniteQuery

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

## Import

```typescript
import {
  createSuspenseInfiniteQuery,
  createSuspenseInfiniteQueryFactory,
} from "@ic-reactor/react"
```

## Basic Usage

```typescript
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,
})
```

## 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

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

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

These five keys are the whole object. To force a refetch from a component, use
the `refetch` returned by `useSuspenseInfiniteQuery()`; outside React, call
`invalidate()`.

---

## Examples

### TanStack Router with Suspense Streaming

Prefetch and stream content:

```typescript
// 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>
  )
}
```

### With Error Boundary

Handle canister errors gracefully:

```typescript
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>
  )
}
```

### Infinite Scroll with Suspend

Combined with IntersectionObserver:

```typescript
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>
  )
}
```

### Select for Flattened Data

Flatten pages at factory level:

```typescript
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>
  )
}
```

---

## createSuspenseInfiniteQueryFactory

For dynamic args generation:

```typescript
const getCategoryProductsQuery = createSuspenseInfiniteQueryFactory(backend, {
  functionName: "getProductsByCategory",
  initialPageParam: 0,
  // Optional: derive logical identity from first-page args
  getKeyArgs: (args) => {
    const [categoryId] = args
    return [categoryId]
  },
  getNextPageParam: (lastPage) => lastPage.nextOffset,
})

// Create with specific category
function CategoryPage({ categoryId }: { categoryId: string }) {
  const query = getCategoryProductsQuery((offset) => [
    categoryId,
    { offset, limit: 20 }
  ])

  const { data } = query.useSuspenseInfiniteQuery()

  return (
    <Suspense fallback={<ProductGridSkeleton />}>
      <ProductGrid products={data.pages.flatMap((p) => p.products)} />
    </Suspense>
  )
}
```

### 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).

```typescript
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 },
  },
])
```

---

## Notes

**Tip:** Use Suspense infinite queries when you want simpler components without
  undefined checks. The parent Suspense boundary handles loading.

**Note:** Initial page is fetched during suspend. Subsequent pages (fetchNextPage) don't
  trigger Suspense — they set `isFetchingNextPage` instead.

**Caution:** There's no `enabled` option in Suspense queries. Use
  [createInfiniteQuery](https://ic-reactor.b3pay.net/v3/reference/factories/createinfinitequery) if you need
  conditional fetching.

## See Also

- [createInfiniteQuery](https://ic-reactor.b3pay.net/v3/reference/factories/createinfinitequery) — Non-Suspense version
- [createSuspenseQuery](https://ic-reactor.b3pay.net/v3/reference/factories/createsuspensequery) — Non-paginated Suspense
- [useActorSuspenseInfiniteQuery](https://ic-reactor.b3pay.net/v3/reference/createactorhooks/useactorsuspenseinfinitequery) — Direct hook