Pagination
Reading a page
Section titled “Reading a page”Generate the URI, fetch it yourself, then hand the raw body to a Paginator. What comes back is a
PaginatedCollection — rows plus the page metadata the backend reported.
const body = await fetch(`https://example.com/api${uri}`).then((r) => r.json());const page = paginator.paginate(body);
page.data; // rowspage.total; // 57page.lastPage; // 6page.from; // 26page.to; // 50Paginator also writes that metadata back into the store, which is what lets the builder answer
questions about where you are.
Typing the rows
Section titled “Typing the rows”paginate<T>() types page.data as T[]. T can be an interface, a class or a type alias —
anything that is an object:
interface Article { id: number; title: string;}
const page = paginator.paginate<Article>(body);page.data[0].title; // stringPassing a page across a serialisation boundary
Section titled “Passing a page across a serialisation boundary”PaginatedCollection is a class, and some boundaries accept only plain data — React Server
Components refuse to pass class instances to Client Components. toPlain() returns a copy that
crosses them:
const page = paginator.paginate<Article>(body).toPlain();
page.data; // Article[] — a new array, the same rowspage.total; // 57page.nextPageUrl; // null — this backend reports no linksThe result is a PaginatedResult<T>: an object literal with data and every pagination field. A
field the backend did not report is null rather than undefined, so JSON.stringify keeps every
key. Rows pass through as the response held them.
Header-based pagination
Section titled “Header-based pagination”Some backends page over headers rather than the body — PostgREST’s Content-Range, WordPress’s
X-WP-TotalPages, the total-count of Rails’ Pagy. Pass the headers as the second argument:
const response = await fetch(url);const page = paginator.paginate(await response.json(), response.headers);Anything with a .get() accessor works — the DOM Headers, Angular’s HttpHeaders — as does a
plain object. The core imports none of them.
PostgREST’s Content-Range places the rows but does not state the page size, so the paginator
takes it from the store’s limit: a short last page, 30-33/34 at a limit of 10, is page 4 of 4.
Parse each response with the paginator whose builder made the request, and keep the limit at or
below the server’s db-max-rows (Supabase’s Max Rows, 1000 by default): PostgREST returns no more
rows than that, and the next page’s offset would skip the rest.
Drivers that need to send pagination headers expose them through paginationHeaders(), which
returns null when the active driver has nothing to add, so it is safe to spread unconditionally:
await fetch(url, { headers: { ...qb.paginationHeaders() } });Moving between pages
Section titled “Moving between pages”Every navigator returns this, so a move and a regenerate is one chain:
const nextUri = qb.nextPage().generateUri();Out-of-range moves are clamped, not thrown — nextPage() on the last page is a no-op.
Syncing
Section titled “Syncing”Until a response has been parsed, the builder does not know how many pages exist. Rather than guess, it throws:
qb.totalPages();// PaginationNotSyncedError: Cannot read totalPages: no paginated response has been synced yet.Paginator.paginate() fixes that for you. If you parse responses yourself, sync manually:
store.syncLastPage(page.lastPage);Normalising rows
Section titled “Normalising rows”PaginatedCollection.normalize() reduces a page to identifiers, keyed by page number — useful when
rows live in a normalised store:
page.normalize(); // { 2: [7, 9] }page.normalize('slug'); // { 2: ['first', 'second'] }page.normalize((row) => `row-${row.id}`); // { 2: ['row-7', 'row-9'] }Building pagination and sort controls
Section titled “Building pagination and sort controls”Four helpers do the arithmetic every list control repeats. They render nothing — the markup is yours.
Page numbers
Section titled “Page numbers”getPageWindow() lists the places a pagination bar shows, with 'gap' where a run of pages is
folded:
import { getPageWindow } from '@qubeejs/core';
getPageWindow(5, 12); // [1, 'gap', 4, 5, 6, 'gap', 12]getPageWindow(1, 12); // [1, 2, 3, 4, 5, 'gap', 12]getPageWindow(12, 12); // [1, 'gap', 8, 9, 10, 11, 12]getPageWindow(3, 5); // [1, 2, 3, 4, 5]The window keeps a constant length — 2 × boundaries + 2 × siblings + 3, seven by default, or
every page when there are fewer — so the buttons do not shift as the user pages, and a gap never
hides a single page. boundaries sets how many pages show at each end, siblings how many either
side of the current one; both are whole numbers, 0 or more:
getPageWindow(10, 20, { boundaries: 2, siblings: 2 });// [1, 2, 'gap', 8, 9, 10, 11, 12, 'gap', 19, 20]It never throws: a page outside 1–lastPage is clamped, and a last page that is 0, NaN or
Infinity — a backend that reports no count — is treated as 1, so the window is just [1], without
the current page. Such a backend cannot drive numbered pages: offer previous and next links
instead.
“21–34 of 34”
Section titled ““21–34 of 34””getPageRange() gives the numbers; the wording stays yours:
import { getPageRange } from '@qubeejs/core';
const { from, to, total } = getPageRange(page); // a PaginatedCollection, or its toPlain()`${from}–${to} of ${total}`; // '21–34 of 34'It uses the backend’s own from and to when the driver reports them, and otherwise computes them
from the page, the page size and the rows. An empty page is { from: 0, to: 0, total }; total is
null when the backend does not count.
Sortable column headers
Section titled “Sortable column headers”import { getAriaSort, toggleSort } from '@qubeejs/core';
toggleSort(sorts, 'title'); // title ascending — or flipped, if it is the primary sorttoggleSort(sorts, 'title', { multiple: true }); // keeps the other sortsgetAriaSort(sorts, 'title'); // 'ascending' | 'descending' | 'none'toggleSort() replaces the other sorts unless multiple is set, and flips a field only when it is the
primary sort — the one getAriaSort() announces; any other field starts ascending. With multiple, a
field flips in place wherever it is. getAriaSort() reports a direction
only for the primary sort — ARIA allows one sorted column per table — and 'none' for every
other column.
