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.
Signature
Section titled “Signature”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.
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'); } }}PaginatedQueryOptions
Section titled “PaginatedQueryOptions”| 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.
PaginatedQueryResult
Section titled “PaginatedQueryResult”| 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.
Client status mapping
Section titled “Client status mapping”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()istrue,status()is'pending'. - Skipped:
results()is[], all four booleans arefalse,isSkipped()istrue, andloadMore()returnsfalse.
Errors
Section titled “Errors”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
onUpdatelistener the client registered after this subscription, including otherinjectQuery()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 itsErrorHandler. 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.
During a server render
Section titled “During a server render”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() returns a boolean — check it
Section titled “loadMore() returns a boolean — check it”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.
Experimental client requirement
Section titled “Experimental client requirement”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.