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 |
— | Invoked when the subscription fails. |
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. |
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().
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”On failure the helper keeps what you already have:
results()is preserved — a failedloadMore()does not empty the list.error()is set andstatus()becomes'error'.isLoadingFirstPage()andisLoadingMore()gofalse.isExhausted()flips back tofalse, even if the list had been exhausted.canLoadMore()becomestrueonly if a subscription had previously handed the helper aloadMorefunction; a first-page failure leaves itfalse.
reset() is the recovery path after a first-page error: it bumps the reset version, which
changes the subscription identity and resubscribes from the first page even when the
arguments are unchanged.
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, or the live subscription has not
synced.
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:
[convex-angular] `injectPaginatedQuery()` requires a Convex client with experimental paginated query support.This 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.