Infinite Queries
Paginated data with real-time subscriptions.
import { useInfiniteQuery } from 'kitcn/react';
import { useCRPC } from '@/lib/crpc';
function SessionList({ userId }: { userId: string }) {
const crpc = useCRPC();
// limit comes from server's .paginated(20) - no need to specify
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery(
crpc.session.list.infiniteQueryOptions({ userId })
);
if (isLoading) return <div>Loading...</div>;
return (
<div>
{data.map((session) => (
<SessionCard key={session.id} session={session} />
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>Load more</button>
)}
</div>
);
}Signature
The infiniteQueryOptions method creates options for infinite queries:
// Get infinite query options from cRPC proxy
// limit defaults to server's .paginated(limit) value
const options = crpc.session.list.infiniteQueryOptions({ userId });
// Or override with a lower value
const options = crpc.session.list.infiniteQueryOptions({ userId }, { limit: 10 });
// Use in hook - options include the function reference
useInfiniteQuery(options)
// Access page size
const limit = crpc.session.list.meta.limit;Prefetching
Prefetch the first page for instant navigation:
// In route loader or parent component
const crpc = useCRPC();
const queryClient = useQueryClient();
await queryClient.prefetchQuery(
crpc.session.list.infiniteQueryOptions({ userId })
);See the Return Value reference for a full list of properties returned by useInfiniteQuery.
infiniteQueryOptions
Options passed to crpc.*.infiniteQueryOptions():
| Option | Type | Description |
|---|---|---|
limit | number | Items per page. Optional if defined in .paginated(limit) on server. Can only be less than or equal to the server limit. |
skipUnauth | boolean | Skip when unauthenticated |
Other TanStack Query options are also supported.
Placeholder Data
Use placeholderData for skeleton UIs while loading:
import type { Id } from '@convex/dataModel';
const crpc = useCRPC();
// Use .meta.limit to match server's page size for placeholder data
const { data, isPlaceholderData } = useInfiniteQuery(
crpc.session.list.infiniteQueryOptions(
{},
{
placeholderData: Array.from({ length: crpc.session.list.meta.limit }).map((_, i) => ({
id: i.toString() as Id<'session'>,
token: 'Loading...',
expiresAt: 0,
})),
}
)
);
return (
<div>
{data.map((session) => (
<WithSkeleton key={session.id} isLoading={isPlaceholderData}>
<SessionCard session={session} />
</WithSkeleton>
))}
</div>
);Infinite Scroll
Combine with an intersection observer for infinite scroll:
function SessionFeed() {
const crpc = useCRPC();
const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery(
crpc.session.list.infiniteQueryOptions({})
);
const { bottomRef } = useInfiniteScroll({
fetchNextPage,
hasNextPage,
isFetching,
});
return (
<div>
{data.map((session) => (
<SessionCard key={session.id} session={session} />
))}
{isFetching && <Spinner />}
<div ref={bottomRef} />
</div>
);
}Backend Setup
Paginated queries use .paginated({ limit, item }) to add pagination input automatically, set the max items per page, and auto-wrap the output:
import { z } from 'zod';
import { publicQuery } from './lib/crpc';
const SessionSchema = z.object({
id: z.string(),
userId: z.string(),
token: z.string(),
});
export const list = publicQuery
.input(z.object({ userId: z.string().optional() }))
.paginated({ limit: 20, item: SessionSchema })
.query(async ({ ctx, input }) => {
// cursor and limit are automatically added to input
// output is auto-wrapped as { continueCursor, isDone, page }
return ctx.orm.query.session.findMany({
where: input.userId ? { userId: input.userId } : undefined,
orderBy: { createdAt: 'desc' },
cursor: input.cursor,
limit: input.limit,
});
});The .paginated({ limit, item }) method:
- Adds flat pagination fields to your input:
cursorandlimit - Auto-sets output schema:
{ continueCursor: string, isDone: boolean, page: T[] }
| Input Field | Type | Description |
|---|---|---|
cursor | string | null | Pagination cursor |
limit | number | Items to fetch (capped at server limit) |
Important: Always call .paginated({ limit, item }) before .query(). The handler receives flat input.cursor and input.limit - pass them to ORM findMany({ cursor, limit }).
Conditional Queries
Use enabled to conditionally run queries:
const crpc = useCRPC();
const [userId, setUserId] = useState<string | null>(null);
const { data } = useInfiniteQuery(
crpc.session.list.infiniteQueryOptions(
{ userId: userId! },
{ limit: 20, enabled: !!userId }
)
);Real-time Updates
Each page maintains its own WebSocket subscription. When data changes on the server:
- Items are automatically updated in place
- New items appear in the appropriate page
- Deleted items are removed
- Page cursors are automatically recovered if invalidated
const crpc = useCRPC();
// This list updates in real-time when sessions are created/updated/deleted
const { data } = useInfiniteQuery(
crpc.session.list.infiniteQueryOptions({ userId })
);Next Steps
API Reference
Return Value
Here's what useInfiniteQuery returns:
| Property | Type | Description |
|---|---|---|
data | T[] | Flattened array of all loaded items |
pages | T[][] | Array of page arrays (raw, not flattened) |
fetchNextPage | (limit?) => void | Load next page |
hasNextPage | boolean | Whether more pages exist |
isLoading | boolean | Loading first page |
isFetchingNextPage | boolean | Loading additional pages |
isFetchNextPageError | boolean | Whether fetching next page failed |
isPlaceholderData | boolean | Showing placeholder data |
status | PaginationStatus | 'LoadingFirstPage' | 'LoadingMore' | 'CanLoadMore' | 'Exhausted' |
error | Error | null | First error encountered |
isFetching | boolean | Any fetch in progress |
isSuccess | boolean | Query succeeded |
Extends TanStack Query's UseQueryResult.