kitcn

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():

OptionTypeDescription
limitnumberItems per page. Optional if defined in .paginated(limit) on server. Can only be less than or equal to the server limit.
skipUnauthbooleanSkip 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:

convex/functions/session.ts
import * as 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, endCursor, and limit are automatically added to input
    return ctx.orm.query.session.findMany({
      where: input.userId ? { userId: input.userId } : undefined,
      orderBy: { createdAt: 'desc' },
      cursor: input.cursor,
      endCursor: input.endCursor,
      limit: input.limit,
    });
  });

The .paginated({ limit, item }) method:

  • Adds flat pagination fields to your input: cursor, endCursor, and limit
  • Accepts the full Convex page result, including pageStatus and splitCursor
Input FieldTypeDescription
cursorstring | nullPagination cursor
endCursorstring | null | undefinedReactive page boundary controlled by the infinite-query hook
limitnumberItems to fetch (capped at server limit)

Important: Always call .paginated({ limit, item }) before .query(). Pass input.cursor, input.endCursor, and input.limit to the matching ORM findMany() fields so reactive page splits stay bounded.

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
  • Oversized reactive pages split into adjacent, boundary-pinned subscriptions
  • An invalid loaded-page cursor resets the list cleanly to its first page
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:

PropertyTypeDescription
dataT[]Flattened array of all loaded items
pagesT[][]Array of page arrays (raw, not flattened)
fetchNextPage(limit?) => voidLoad next page
hasNextPagebooleanWhether more pages exist
isLoadingbooleanLoading first page
isFetchingNextPagebooleanLoading additional pages
isFetchNextPageErrorbooleanWhether fetching next page failed
isPlaceholderDatabooleanShowing placeholder data
statusPaginationStatus'LoadingFirstPage' | 'LoadingMore' | 'CanLoadMore' | 'Exhausted'
errorError | nullFirst error encountered
isFetchingbooleanAny fetch in progress
isSuccessbooleanQuery succeeded

Extends TanStack Query's UseQueryResult.

On this page