Skip to content

Pagination

injectPaginatedQuery() subscribes to a Convex paginated query and accumulates pages into a single growing results() array — the primitive behind “load more” buttons and infinite scroll.

function injectPaginatedQuery<Query extends PaginatedQueryReference>(
query: Query,
argsFn: () => PaginatedQueryArgs<Query> | SkipToken,
options: PaginatedQueryOptions<Query>,
): PaginatedQueryResult<Query>;
type PaginatedQueryReference = FunctionReference<
'query',
'public',
{ paginationOpts: PaginationOptions },
PaginationResult<any>
>;
type PaginatedQueryArgs<Query> = Omit<FunctionArgs<Query>, 'paginationOpts'>;
type PaginatedQueryItem<Query> = FunctionReturnType<Query>['page'][number];

The argument function returns your query’s arguments without paginationOpts — the helper supplies those.

paginated-todos.component.ts
import { Component, signal } from '@angular/core';
import { injectPaginatedQuery, skipToken } from 'convex-angular';
import { api } from './convex/api';
@Component({
selector: 'app-paginated-todos',
template: `
@switch (todos.status()) {
@case ('skipped') {
<p>Pick a category.</p>
}
@case ('pending') {
<p>Loading first page…</p>
}
@case ('error') {
<p role="alert">{{ todos.error()?.message }}</p>
<button type="button" (click)="todos.reset()">Start over</button>
}
@case ('success') {
<ul>
@for (todo of todos.results(); track todo._id) {
<li>{{ todo.title }}</li>
}
</ul>
}
}
<!-- isLoadingMore() is also part of the disabled condition: during a
load-more round trip the client reports canLoadMore === false. -->
@if (!todos.isExhausted()) {
<button type="button" [disabled]="!todos.canLoadMore() || todos.isLoadingMore()" (click)="loadMore()">
{{ todos.isLoadingMore() ? 'Loading…' : 'Load more' }}
</button>
}
`,
})
export class PaginatedTodosComponent {
readonly category = signal<string | null>('work');
// `initialNumItems` accepts a plain number or a Signal. Changing the
// signal changes the subscription identity and restarts pagination.
readonly pageSize = signal(10);
readonly todos = injectPaginatedQuery(
api.todos.listPaginatedByCategory,
() => {
const category = this.category();
return category === null ? skipToken : { category };
},
{
initialNumItems: this.pageSize,
onSuccess: (results) => console.log('page loaded, total items:', results.length),
onError: (error) => console.error(error),
},
);
loadMore(): void {
// `loadMore()` reports whether the request was actually started. After
// hydration it can return false while `canLoadMore()` is already true.
const started = this.todos.loadMore(this.pageSize());
if (!started) {
console.debug('load more not started yet — the subscription has not synced');
}
}
}
Name Type Default Description
initialNumItems number | Signal<number> — (required) Page size for the first page. A signal is read reactively; changing it restarts pagination.
injectRef EnvironmentInjector ambient injector Injector used to create the query outside the current injection context.
onSuccess (results: PaginatedQueryItem<Query>[]) => void — Invoked with the accumulated results on every emission except LoadingFirstPage.
onError (err: Error) => void — Server render only today: invoked when the first-page fetch fails. See Errors.

initialNumItems is part of the subscription identity. A different page size means a different subscription, so changing the signal tears the current one down and reloads from the first page.

onSuccess is skipped for LoadingFirstPage emissions because the results are not complete yet, and — like the other helpers — it does not fire for a first page seeded from a server render during hydration.

Name Type Description
results Signal<PaginatedQueryItem<Query>[]> Accumulated items across all loaded pages. [] before the first page and while skipped.
error Signal<Error | undefined> Latest error; cleared on the next successful emission. Only a server-render or mock-driven failure sets it: see Errors.
isLoadingFirstPage Signal<boolean> True while the first page is loading.
isLoadingMore Signal<boolean> True while an additional page is loading.
canLoadMore Signal<boolean> True when the client reports more items are available.
isExhausted Signal<boolean> True when every item has been loaded.
isSkipped Signal<boolean> True when the argument function returned skipToken.
isSuccess Signal<boolean> !isLoadingFirstPage() && !isSkipped() && !error().
status Signal<PaginatedQueryStatus> 'pending' | 'success' | 'error' | 'skipped'.
loadMore (numItems: number) => boolean Request more items. Returns whether loading was actually initiated.
reset () => void Discard loaded pages and reload from the first page.

status() is derived exactly as for a single query:

status = isSkipped() ? 'skipped' : isLoadingFirstPage() ? 'pending' : error() ? 'error' : 'success';

Note that 'success' covers “first page loaded” — the list may still have more pages to fetch. Use canLoadMore() / isExhausted() for that distinction, not status().

reset() bumps a version that changes the subscription identity, so it resubscribes from the first page even when the arguments are unchanged.

The Convex client reports one of four pagination statuses on every emission. The helper maps each to a fixed combination of signals:

Client status isLoadingFirstPage isLoadingMore canLoadMore isExhausted status()
LoadingFirstPage true false false false 'pending'
LoadingMore false true false false 'success'
CanLoadMore false false true false 'success'
Exhausted false false false true 'success'

Exactly one of the four booleans is true at a time. In particular canLoadMore() is false during a LoadingMore round trip, so a button disabled on !canLoadMore() is automatically disabled while the next page is in flight.

results() is updated on every emission, including LoadingFirstPage.

Two other states fall outside the table:

  • Before the first change detection, the result reads as an empty first page still loading: results() is [], isLoadingFirstPage() is true, status() is 'pending'.
  • Skipped: results() is [], all four booleans are false, isSkipped() is true, and loadMore() returns false.

In the browser: a failed page query is not reported

Section titled “In the browser: a failed page query is not reported”

The failure has these consequences:

  • Every onUpdate listener the client registered after this subscription, including other injectQuery() calls, stops receiving updates until the failed subscription is torn down or a later server run of that page succeeds.
  • The transition that carried the failure is dropped for every listener, so other query updates batched into it are lost until those queries change again.
  • reset() does not throw, but the resubscription it triggers does while the failed page is still cached, and Angular reports that to its ErrorHandler. The client still asks the server again, and if that run succeeds the list recovers. error() never reports the failure, and the helper loses its handle on the new subscription, so it is never unsubscribed.

The failed transition happens inside the Convex client (observed with convex@1.49.0), where the library cannot intercept it. Make paginated query functions return an empty page or an explicit result for expected failures instead of throwing.

The server render fetches the first page once over HTTP. When that fetch fails, onError is called, error() holds the Error, status() is 'error', isLoadingFirstPage() is false, and results() is []. canLoadMore(), isLoadingMore() and isExhausted() stay false.

loadMore: (numItems: number) => boolean;

It returns false when no request was started: nothing is subscribed yet, the query is skipped, a load is already in flight, the list is exhausted, the live subscription has not synced, or the owning injection context has been destroyed.

The helper is built on Convex’s experimental paginated subscription API (onPaginatedUpdate_experimental). If the injected client does not expose it, the subscription effect throws this error. It surfaces during change detection, not at injection time. If you see it in a test, the mock client provided for CONVEX is missing onPaginatedUpdate_experimental.