BETTER-CONVEX

Infinite Queries

Paginated data with real-time subscriptions.

In this guide, we'll explore infinite queries in cRPC. You'll learn to implement cursor-based pagination with real-time updates, prefetch first pages, handle loading states with placeholders, and set up infinite scroll.

Overview

useInfiniteQuery provides cursor-based pagination with real-time updates:

FeatureBenefit
Cursor-basedEfficient pagination with stable cursors
Real-timeEach page maintains WebSocket subscription
Server-defined limitsPage size from .paginated(limit)
PrefetchingFirst-page prefetch for instant navigation

Let's see how it works.

import { useInfiniteQuery } from 'better-convex/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 })
);

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.

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 { z } from 'zod';
import { zid } from 'convex-helpers/server/zod4';
import { publicQuery } from './lib/crpc';

const SessionSchema = z.object({
  _id: zid('session'),
  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.db
      .query('session')
      .withIndex('userId', (q) =>
        input.userId ? q.eq('userId', input.userId) : q
      )
      .order('desc')
      .paginate({ cursor: input.cursor, numItems: input.limit });
  });

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

  • Adds flat pagination fields to your input: cursor and limit
  • Auto-sets output schema: { continueCursor: string, isDone: boolean, page: T[] }
Input FieldTypeDescription
cursorstring | nullPagination cursor
limitnumberItems to fetch (capped at server limit)

Important: Always call .paginated({ limit, item }) before .query(). The handler receives flat input.cursor and input.limit which you transform to Convex's .paginate({ cursor, numItems }).

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 })
);

How It Works

Let's understand how infinite queries work under the hood.

Pagination State Persistence

Pagination state is stored in queryClient for scroll restoration across navigations:

Mount ──► Check queryClient for existing state

          ┌─────────┴─────────┐
          │                   │
     State found         Not found
          │                   │
    Restore pages        Create initial state
    Resume subscriptions Subscribe to first page
          │                   │
          └─────────┬─────────┘

            Navigate away

        ┌───────────┴───────────┐
        │                       │
   State persists          Subscriptions close
   in queryClient          (after unsubscribeDelay)
        │                       │
        └───────────┬───────────┘

             Navigate back

          Instant cached data
          + re-subscribe to pages

Per-Page Subscriptions

Each page maintains its own WebSocket subscription:

Page 0 ──► WebSocket subscription ──► Real-time updates
Page 1 ──► WebSocket subscription ──► Real-time updates
Page 2 ──► WebSocket subscription ──► Real-time updates

               └─ All pages update independently

Automatic Error Recovery

The system handles errors gracefully:

InvalidCursor error ──► Reset pagination state
                        Start fresh from page 0
                        New subscriptions

Page too large ──► splitCursor returned
                   Automatically split page
                   Maintain continuity

Migrate from Convex

If you're coming from vanilla Convex, here's what changes.

What stays the same

  • Cursor-based pagination model
  • Real-time updates via WebSocket

What's new

Before (vanilla Convex):

import { usePaginatedQuery } from 'convex/react';
import { api } from '@convex/_generated/api';

const { results, status, loadMore } = usePaginatedQuery(
  api.session.list,
  { userId },
  { initialNumItems: 20 }
);

// Check status
if (status === 'LoadingFirstPage') return <Loading />;

// Load more
<button onClick={() => loadMore(20)}>Load more</button>

After (cRPC):

import { useInfiniteQuery } from 'better-convex/react';
import { useCRPC } from '@/lib/crpc';

const crpc = useCRPC();

// limit comes from server's .paginated(20)
const { data, hasNextPage, isLoading, fetchNextPage } = useInfiniteQuery(
  crpc.session.list.infiniteQueryOptions({ userId })
);

// Check status
if (isLoading) return <Loading />;

// Load more (optional: pass lower value)
<button onClick={() => fetchNextPage()}>Load more</button>

Key differences:

BeforeAfter
resultsdata (same flattened array)
status === 'LoadingFirstPage'isLoading
status === 'CanLoadMore'hasNextPage
status === 'LoadingMore'isFetchingNextPage
loadMore(n)fetchNextPage(n) (optional, uses server limit)
Manual limitServer-defined via .paginated(20)
No prefetchingqueryClient.prefetchQuery() support

Additional benefits:

  • placeholderData option for skeleton UIs
  • skipUnauth option for auth-aware queries
  • Page splitting handled automatically

Next Steps

On this page