Skip to content

Pagination

WarpDrive's pagination primitives turn the request that loads the first page of a collection into a reactive object that knows about the whole collection: which pages have loaded, which one is active, and how to reach the rest. Rendering a numbered pager, an infinite feed, or both over the same data becomes a matter of choosing which parts of that state to render.

The API is published from two entry points:

Install whichever your app does not already have:

sh
pnpm add @warp-drive/experiments @warp-drive/ember

Nothing else needs configuring. The primitives work with any store whose cache preserves a document's links and meta, which the @warp-drive/json-api cache does. An app on the ember-data meta package already has that cache, a RequestManager and the legacy network handler set up (see @ember-data/store).

⚠️ Experimental

Like everything under Experiments, this API has not been through an RFC and may change or be removed in a minor release.

Why Pagination Primitives?

Paginated lists are where request code stops being simple. The list needs to know which pages are loaded and which one the user is looking at. Two components showing the same list, a table and a summary count for instance, should not load its pages twice. Each page load has its own loading and error state, separate from the list as a whole. And the same data often has to power two different UIs, a numbered pager on desktop and an infinite feed on mobile.

The pagination primitives handle this by splitting the state in two:

  • A shared page cache for each collection. Every component paginating the same collection reads from the same cache, so a page loaded by one is available to all of them. Collections are identified by the first link of their response document (or self when there is no first), which is why the primitives need pagination links to work.
  • A per-consumer pagination state that holds the local navigation state: the active page in a paged UI, or the loaded run of pages in an infinite one. It is keyed by the identity of the request that loaded the first page, the same way getRequestState is, so a template and the JavaScript behind it observing the same request share one state.

Both pieces are reactive. Reading totalPages in a template re-renders when a later page reveals it, and a page loaded through one component's links appears in every other component's state. For the reactivity model these primitives build on, see Reactive Control Flow.

The primitives navigate by following the links a response document carries. A response document is the structured form a request's response takes once it is in the cache: its data, plus links and meta about the response as a whole (see Using the Response). A page's document should expose some of:

json
{
  "data": [],
  "links": {
    "self": "/users?page=2",
    "first": "/users?page=1",
    "prev": "/users?page=1",
    "next": "/users?page=3",
    "last": "/users?page=10"
  }
}

This is the {json:api} convention, and the @warp-drive/json-api cache preserves a compliant response's links as-is. Only self and the links that exist are required: a cursor-based API that exposes just prev and next works, it simply has no page numbers to render.

Your API does not generate links?

Add a request handler that computes the links for paginated responses before they reach the cache. This is worth doing even if you never use <Paginate />, since several WarpDrive features work best when documents carry links.

Two Modes: Paged and Infinite

A pagination state exposes two navigation surfaces over the same shared cache.

Paged is a single-page view. Render the active page, and navigate by loading a specific page URL, usually from the links the response provides. activePageRequest is a Future, the same kind of request object store.request() returns.

ts
interface PagedPaginationState<RT, E> {
  activePage: PageCache<RT, E> | null;
  activePageRequest: Future<RT> | null;
  totalPages: number;
  loadPage: (url: string) => Promise<RT | null>;
}

Infinite is an accumulating view. Render data, the items of every loaded page in order, and grow it at either end. pages is the same run as data but yields each page's PageCache, for UIs that need page boundaries or per-page request state.

ts
interface InfinitePaginationState<RT, E> {
  data: Iterable<ContentItem<RT>>;
  pages: Iterable<PageCache<RT, E>>;
  hasNext: boolean;
  hasPrevious: boolean;
  nextRequest: Future<RT> | null;
  previousRequest: Future<RT> | null;
  totalPages: number;
  loadNext: () => Promise<RT | null>;
  loadPrev: () => Promise<RT | null>;
}

In JavaScript, a PaginationState carries both surfaces and you use whichever fits. The <Paginate /> component's @mode arg ('paged', the default, or 'infinite') narrows what it yields to one surface so the two cannot be mixed by accident. The mode is type-only and is never read at runtime.

Using the Component API

<Paginate /> is declarative control flow in the style of <Request />. It renders no markup of its own. Give it the request that loads the first page, either as @request, a Future your code already issued with store.request, or as @query, a request object such as a builder's return value that the component issues for you. It yields the pagination state to one of its blocks:

  • idle, loading, cancelled and error describe the initial request only. Later page loads never re-enter loading; they surface through the individual page requests instead.
  • content renders once the first page is loaded. It receives pages, the pagination state narrowed to the component's mode, and features, the controls for the initiating request.
  • always renders in every state, and a default block replaces all named blocks and renders regardless of request state, for components that manage control flow themselves. Both receive the same params as content.

The features object carries the same request controls <Request /> yields to its content block (refresh, reload, isRefreshing, abort, latestRequest, isOnline, isHidden), plus isNavigating (see Route-Driven Navigation) and the mode's navigation entry point: loadPage in paged mode, loadNext and loadPrev in infinite mode. <Paginate /> also accepts the same @autorefresh, @autorefreshThreshold and @autorefreshBehavior args as <Request />; see its API docs for what they do.

In paged mode, pages.activePageRequest is a plain request, so wrap it in <Request /> for the active page's own loading and error states. Its result.data holds the same record instances the store hands out everywhere else, so in a legacy app they are your Model instances and their relationships work as usual. <EachLink /> yields the navigation links derived from the same state; you decide which to render and how.

Ember
glimmer-ts
import { Request } from '@warp-drive/ember';
import { EachLink, Paginate } from '@warp-drive/ember/experiments';

<template>
  <Paginate @request={{@request}}>
    <:loading><Spinner /></:loading>

    <:content as |pages|>
      <Request @request={{pages.activePageRequest}}>
        <:loading><Spinner /></:loading>
        <:error as |error|><ErrorForm @error={{error}} /></:error>
        <:content as |result|>
          {{#each result.data as |user|}}
            <UserRow @user={{user}} />
          {{/each}}
        </:content>
      </Request>

      <EachLink @pages={{pages}} as |state|>
        {{#if state.prev}}
          <button {{on "click" state.prev.setActive}}>Previous</button>
        {{/if}}

        {{#each state.links as |link|}}
          {{#if link.isReal}}
            <button class={{if link.isCurrent "active"}} {{on "click" link.setActive}}>{{link.text}}</button>
          {{else}}
            <span>…</span>
          {{/if}}
        {{/each}}

        {{#if state.next}}
          <button {{on "click" state.next.setActive}}>Next</button>
        {{/if}}
      </EachLink>
    </:content>

    <:error as |error state|>
      <ErrorForm @error={{error}} />
      <button {{on "click" state.retry}}>Retry</button>
    </:error>
  </Paginate>
</template>

The state <EachLink /> yields is a PaginationLinks:

  • links are the numbered links. Gaps of not-yet-loaded pages are represented by placeholders, so check isReal before rendering a link as a button. A real link has index, text, isCurrent and a stable setActive action that loads its page and makes it active. This list is empty for cursor-based collections.
  • prev and next are the relational links for the active page, or null at the collection's edges. For cursor-based collections they are the only navigation.
  • first and last are the links to the collection's edges, when the response exposes them. They are usually present on every page, including the edge page itself, where isCurrent is true so you can disable the control.

Infinite: Render an Accumulating List

In infinite mode, render pages.data and grow it with features.loadNext or features.loadPrev. pages.nextRequest and pages.previousRequest are null until a load is triggered, then hold the in-flight request until it succeeds. If a load fails they keep pointing at the failed request, so wrapping them in <Request /> gives you a loading state, an error state with retry, and a load-more button from its idle block, all placed wherever your layout wants them.

Ember
glimmer-ts
import { Request } from '@warp-drive/ember';
import { Paginate } from '@warp-drive/ember/experiments';

<template>
  <Paginate @request={{@request}} @mode="infinite">
    <:loading><Spinner /></:loading>

    <:content as |pages features|>
      {{#each pages.data as |user|}}
        <UserRow @user={{user}} />
      {{/each}}

      {{#if pages.hasNext}}
        <Request @request={{pages.nextRequest}}>
          <:idle><button {{on "click" features.loadNext}}>Load more</button></:idle>
          <:loading><Spinner /></:loading>
          <:error as |error state|>
            <ErrorForm @error={{error}} />
            <button {{on "click" state.retry}}>Retry</button>
          </:error>
        </Request>
      {{/if}}
    </:content>

    <:error as |error state|>
      <ErrorForm @error={{error}} />
      <button {{on "click" state.retry}}>Retry</button>
    </:error>
  </Paginate>
</template>

Because pages.loadNext and pages.loadPrev are plain functions, they also pair directly with virtual-scroll components that fire a callback when the user reaches an end of the list.

Using the JS API

getPaginationState returns the pagination state for a request. Calling it again with the same request returns the same instance.

ts
import { getPaginationState } from '@warp-drive/experiments/pagination';

const request = store.request({ url: '/users', method: 'GET' });
const pages = getPaginationState(request);

await request;
pages.totalPages; // known now, if the response exposed it
await pages.loadNext();

for (const user of pages.data) {
  // the first two pages
}

getPaginationLinks derives the same links object <EachLink /> yields, for building navigation outside a template:

ts
import { getPaginationLinks, getPaginationState } from '@warp-drive/experiments/pagination';

const pages = getPaginationState(request);
const links = getPaginationLinks(pages);

links.next?.setActive();

createPaginationSubscription creates the lifecycle object <Paginate /> builds for itself: a PaginationSubscription that owns the request subscription for the initial request (loading and error state, autorefresh) together with its pagination state. Create one yourself and pass it to the component's @subscription arg when the lifecycle should outlive a single component or be shared between several; the component then uses it instead of creating and tearing down its own, and tearing it down becomes your responsibility.

ts
import { createPaginationSubscription } from '@warp-drive/experiments/pagination';

const subscription = createPaginationSubscription(store, { request });

subscription.paginationState; // the PaginationState the component would yield

Page Hints

The state learns totalPages and each page's number from the response. By default it reads meta.page or meta.currentPage and meta.totalPages from the document (see defaultPageHints). When your API puts them elsewhere, provide a PageHints function:

ts
import type { PageHints } from '@warp-drive/experiments/pagination';

export const pageHints: PageHints = (document) => {
  const info = document.meta?.pageInfo as { index: number; count: number } | undefined;
  return { currentPage: info?.index ?? 0, totalPages: info?.count ?? 0 };
};

Pass it as the second argument of getPaginationState, or as @pageHints on <Paginate />.

Write your own hints whenever meta.page is anything other than the current page number. A {json:api} API that reports meta: { page: { total: 10 } } is the common case: the default hints would read that object as the page number. Return 0 for a value the response does not expose; 0 means "unknown", and the state falls back to prev and next navigation for whatever it cannot number.

Page hints attach to the shared cache, so they belong to the collection rather than to any one component. Every consumer of a collection must pass the same function reference: define it once at module scope and import it everywhere, as above. The first hint a collection receives is installed on its cache; a consumer that later passes a different function for the same collection fails a development-mode assertion, and in production its hint is silently ignored.

For cursor-based APIs with no page numbers, leave the hints out. totalPages stays 0, the numbered links stay empty, and navigation happens through prev and next.

Route-Driven Navigation

When the active page is part of the URL, a ?page= query param the route turns into a request for instance, the @request arg changes as the user navigates. <Paginate /> does not tear down when that happens. The existing content stays rendered with features.isNavigating set to true while the new request resolves. A request that resolves to a page of the same collection becomes the active page; one that resolves to a different collection resets the state as if it were a fresh start. The browser back button therefore works without any extra code.

In this setup the route owns the page number, so the page links should change the URL rather than call setActive, which loads a page without touching the URL. A numbered link's index is its page number:

Ember
glimmer-ts
import { LinkTo } from '@ember/routing';
import { Request } from '@warp-drive/ember';
import { EachLink, Paginate } from '@warp-drive/ember/experiments';

<template>
  <Paginate @request={{@model.request}}>
    <:content as |pages|>
      <Request @request={{pages.activePageRequest}}>
        <:content as |result|>
          {{#each result.data as |post|}}<PostRow @post={{post}} />{{/each}}
        </:content>
      </Request>

      <EachLink @pages={{pages}} as |state|>
        {{#each state.links as |link|}}
          {{#if link.isReal}}
            <LinkTo @route="posts" @query={{hash page=link.index}}>{{link.text}}</LinkTo>
          {{/if}}
        {{/each}}
      </EachLink>
    </:content>
  </Paginate>
</template>

with the route declaring queryParams = { page: { refreshModel: true } } and building the request from page as shown in Coming from Legacy Queries.

Coming from Legacy Queries

The legacy store.query and store.findAll methods resolve to an array of records. The pagination primitives need the request itself, because the request's document is where the links and meta live. Replace the call with store.request and a request builder, and keep the page parameters in the query:

ts
export default class PostsRoute extends Route {
  @service declare store: Store;

  model() {
    return this.store.query('post', { page: { number: 1, size: 25 } });
  }
}
ts
import { query } from '@warp-drive/utilities/json-api';

export default class PostsRoute extends Route {
  @service declare store: Store;

  model() {
    // Return the request inside an object. A Future is a promise, so returning it
    // directly would make the route wait for the response and hand the template
    // the resolved document instead of the request `<Paginate />` needs.
    return { request: this.store.request(query('post', { 'page[number]': 1, 'page[size]': 25 })) };
  }
}

The template then passes @model.request to <Paginate />. If the request is created in the same component that renders it, pass the builder's result as @query instead and skip store.request altogether.

API Reference

Released under the MIT License.