Skip to content

Pagination

Use take and skip to implement pagination for large datasets.

Quick Navigation

Basic Pagination

Page numbers are usually one-based in the UI. Convert them to an offset with (page - 1) * pageSize:

ts
const page = 2;
const pageSize = 20;

const rows = await dataStore.products
  .sort(product => product._id)
  .skip((page - 1) * pageSize)
  .take(pageSize)
  .toArrayAsync();

Always sort before skip() and take(). Without a stable order, the same row can move between pages even when the underlying data has not changed.

Simple Take and Skip

ts
// Get first 10 items
const firstPage = await dataStore.products.take(10).toArrayAsync();

// Skip first 10, get next 10
const secondPage = await dataStore.products.skip(10).take(10).toArrayAsync();

Pagination with Filtering

Paginate filtered results:

ts
const expensiveProductsPage = await dataStore.products
  .where((p) => p.price > 100)
  .sort((p) => p.price)
  .skip(20)
  .take(10)
  .toArrayAsync();

Pagination with Sorting

Paginate sorted results:

ts
const sortedProductsPage = await dataStore.products
  .sortDescending((p) => p.price)
  .skip(0)
  .take(5)
  .toArrayAsync();

Reactive Pagination

Pagination needs only two pieces of state: page and pageSize. Include both in useQuery's dependencies so changing either value cleans up the old subscription and executes the newly calculated window.

tsx
import { useState } from "react";
import { useQuery } from "@routier/react";
import { useDataStore } from "../../useDataStore";

export function ProductsPage() {
  const store = useDataStore(); // Keep this instance stable (Context or useMemo).
  const [page, setPage] = useState(1);
  const [pageSize, setPageSize] = useState(20);

  const products = useQuery(
    callback =>
      store.products
        .sort(product => product._id)
        .skip((page - 1) * pageSize)
        .take(pageSize)
        .subscribe()
        .toArray(callback),
    [store, page, pageSize],
  );

  if (products.status === "pending") return <p>Loading…</p>;
  if (products.status === "error") return <p>{String(products.error)}</p>;

  return (
    <>
      <select
        value={pageSize}
        onChange={event => {
          setPageSize(Number(event.target.value));
          setPage(1);
        }}
      >
        <option value={10}>10</option>
        <option value={20}>20</option>
        <option value={50}>50</option>
      </select>

      {products.data.map(product => <div key={product._id}>{product.name}</div>)}

      <button disabled={page === 1} onClick={() => setPage(value => value - 1)}>
        Previous
      </button>
      <span>Page {page}</span>
      <button onClick={() => setPage(value => value + 1)}>Next</button>
    </>
  );
}

The pagination calculation remains one line:

ts
.skip((page - 1) * pageSize).take(pageSize)

There is no separate pagination controller or synchronization layer. React state selects the window, useQuery rebuilds it when that state changes, and Routier supplies the current rows.

React 19 Suspense Pagination

React 19's use() can read the Promise returned by Routier's async terminal methods. Put the reader under <Suspense> and create a new Promise when page or pageSize changes:

tsx
import { Suspense, use, useState } from "react";
import { useDataStore } from "../../useDataStore";

type AppDataStore = ReturnType<typeof useDataStore>;

function loadPage(store: AppDataStore, page: number, pageSize: number) {
  return store.products
    .sort(product => product._id)
    .skip((page - 1) * pageSize)
    .take(pageSize)
    .toArrayAsync();
}

type PagePromise = ReturnType<typeof loadPage>;

function ProductRows({ pagePromise }: { pagePromise: PagePromise }) {
  // React shows the nearest Suspense fallback until this Promise resolves.
  const products = use(pagePromise);

  return products.map(product => (
    <div key={product._id}>{product.name}</div>
  ));
}

export function SuspenseProductsPage() {
  const store = useDataStore(); // Keep this instance stable (Context or useMemo).
  const [request, setRequest] = useState(() => ({
    page: 1,
    pageSize: 20,
    promise: loadPage(store, 1, 20),
  }));

  function showPage(page: number, pageSize: number) {
    // Create the Promise in the event/state update, not in ProductRows' render.
    setRequest({ page, pageSize, promise: loadPage(store, page, pageSize) });
  }

  return (
    <>
      <select
        value={request.pageSize}
        onChange={event => showPage(1, Number(event.target.value))}
      >
        <option value={10}>10</option>
        <option value={20}>20</option>
        <option value={50}>50</option>
      </select>

      <Suspense fallback={<p>Loading page…</p>}>
        <ProductRows pagePromise={request.promise} />
      </Suspense>

      <button
        disabled={request.page === 1}
        onClick={() => showPage(request.page - 1, request.pageSize)}
      >
        Previous
      </button>
      <span>Page {request.page}</span>
      <button onClick={() => showPage(request.page + 1, request.pageSize)}>
        Next
      </button>
    </>
  );
}

The Promise is deliberately created in the parent component's state initializer and event handler. Do not call toArrayAsync() directly inside ProductRows: every retry would create a different Promise and suspend again. A framework-provided cache or an external request cache is another valid way to provide a stable Promise.

This version is a one-time read per page request. It does not use .subscribe(), so later database mutations do not refresh the visible page automatically. Use the preceding useQuery example when the active page must remain live, and use Suspense when Promise-based loading and a boundary fallback match the desired UI. Rejected query Promises go to the nearest React error boundary, not the Suspense fallback.

What Changes Trigger

Two kinds of change are involved:

  1. page or pageSize changes: these are application-state changes. Because they are in the dependency array, useQuery unsubscribes from the previous query and subscribes to the new page.
  2. Stored data changes: .subscribe() keeps the currently selected page live. Adds, updates, or removals that affect its ordered window cause the query to run again and deliver the new page contents.

Changing a captured variable by itself does not modify an already-built query. Outside React, explicitly unsubscribe and create a new subscription when page or pageSize changes. In React, the dependency array performs that lifecycle for you.

When page size changes, resetting to page 1 usually avoids landing beyond the end of the result set. To display a known final page or disable Next, run a separate subscribed count query and calculate Math.ceil(count / pageSize).

Released under the MIT License.