Skip to content
Guides contents

GuidesQueries & schemas

Pagination & infinite scroll

Choose a growing live window or keyset pages, and define stable ordering and cursors.

View as Markdown

A paginated query maintains a bounded result while its source rows change. Choose between one growing window and several fixed-size pages:

Pattern Use it for Tradeoff
Grow limit A contiguous live list The view grows with the visible list
Fixed cursor per page Independently retained pages Live edits can create gaps or duplicates between pages

These recipes extend the synced-app quickstart. They import its q builder from shared/app-def.ts and use its issue table. The components must render under its existing Rindle provider. The query definitions also work with a local store that has the same schema.

Define a complete order

Both recipes order issues by updatedAt, then by id. The unique ID resolves ties between equal timestamps. A cursor must carry both values in that same order.

Add these named queries:

// shared/pagination.ts
import { defineQuery } from "@rindle/client";
import { z } from "zod";
import { q } from "./app-def.ts";

export const PAGE_SIZE = 50;
export const MAX_VISIBLE = 500;

const pageArgs = z.object({
  limit: z.number().int().min(1).max(MAX_VISIBLE),
});

export const paginatedIssuesQuery = defineQuery(
  "paginatedIssues",
  (raw) => pageArgs.parse(raw),
  ({ limit }) => q.issue
    .select("id", "title", "status", "updatedAt")
    .orderBy("updatedAt", "desc")
    .orderBy("id", "desc")
    .limit(limit),
);

const cursorSchema = z.object({ updatedAt: z.number(), id: z.string() });
const afterArgs = z.object({ cursor: cursorSchema.nullable() });
export type IssueCursor = z.infer<typeof cursorSchema>;

export const issuesAfterQuery = defineQuery(
  "issuesAfter",
  (raw) => afterArgs.parse(raw),
  ({ cursor }) => {
    const query = q.issue
      .select("id", "title", "status", "updatedAt")
      .orderBy("updatedAt", "desc")
      .orderBy("id", "desc")
      .limit(PAGE_SIZE);
    return cursor ? query.start(cursor, { exclusive: true }) : query;
  },
);

For the synced example, import both query definitions in server/api.ts. Add them to its existing registerQueries<User>([...]) array, alongside issuesPageQuery. The same server authorization rules apply to each page.

Grow one live window

Increasing the limit creates a new query with the same ordering. The local store can reuse retained rows while the server supplies the larger result.

// src/PaginatedIssues.tsx
import { useState } from "react";
import { useQuery, useQueryStatus } from "@rindle/react";
import { MAX_VISIBLE, PAGE_SIZE, paginatedIssuesQuery } from "../shared/pagination.ts";

export function PaginatedIssues() {
  const [limit, setLimit] = useState(PAGE_SIZE);
  const query = paginatedIssuesQuery({ limit });
  const rows = useQuery(query);
  const status = useQueryStatus(query);
  const complete = status === "complete";

  return (
    <section aria-busy={!complete}>
      <ul>{rows.map((row) => <li key={row.id}>{row.title}</li>)}</ul>
      {!complete && <p>Loading the current window…</p>}
      {complete && rows.length === 0 && <p>No issues.</p>}
      {complete && rows.length === limit && limit < MAX_VISIBLE && (
        <button onClick={() => setLimit((value) => Math.min(value + PAGE_SIZE, MAX_VISIBLE))}>
          Load more
        </button>
      )}
    </section>
  );
}

Render <PaginatedIssues /> in the quickstart’s app. The example waits for complete coverage before deciding whether another page is available. A full window can still be the final window. Increasing its limit discovers that boundary.

The result contains at most limit rows, ordered over the data available to the view. In a synced client, visible rows can include local predictions before server coverage completes. After coverage completes, the server has supplied the requested result.

As rows arrive or change, this single window remains contiguous for its query. Its storage and maintenance cost grows with its limit. The example caps that growth at 500 rows.

The React provider retains released queries for two seconds by default. This retention helps the replacement query reuse local rows. It does not guarantee that every navigation or network response finishes in that interval. See client query retention.

Retain fixed-size pages

A keyset cursor is the last row’s sort values. .start(cursor, { exclusive: true }) selects rows after those values. It follows the query’s descending order in this example.

// src/IssueFeed.tsx
import { useState } from "react";
import { useQuery, useQueryStatus } from "@rindle/react";
import { issuesAfterQuery, PAGE_SIZE } from "../shared/pagination.ts";
import type { IssueCursor } from "../shared/pagination.ts";

export function IssueFeed() {
  const [cursors, setCursors] = useState<Array<IssueCursor | null>>([null]);
  return (
    <section>
      <button onClick={() => setCursors([null])}>Reset pages</button>
      {cursors.map((cursor, index) => (
        <IssuePage
          key={index}
          cursor={cursor}
          isLast={index === cursors.length - 1}
          onNext={(next) => setCursors((current) =>
            current.length === index + 1 ? [...current, next] : current,
          )}
        />
      ))}
    </section>
  );
}

function IssuePage({ cursor, isLast, onNext }: {
  cursor: IssueCursor | null;
  isLast: boolean;
  onNext: (cursor: IssueCursor) => void;
}) {
  const query = issuesAfterQuery({ cursor });
  const rows = useQuery(query);
  const status = useQueryStatus(query);
  const last = rows.at(-1);
  const complete = status === "complete";

  return (
    <div aria-busy={!complete}>
      <ul>{rows.map((row) => <li key={row.id}>{row.title}</li>)}</ul>
      {!complete && <p>Loading this page…</p>}
      {isLast && complete && rows.length === PAGE_SIZE && last && (
        <button onClick={() => onNext({ updatedAt: last.updatedAt, id: last.id })}>
          Load next page
        </button>
      )}
      {isLast && complete && rows.length < PAGE_SIZE && <p>End of the current result.</p>}
    </div>
  );
}

Render <IssueFeed /> as an alternative to <PaginatedIssues />. Each mounted page retains its own query. The final page can append its successor only once. The reset button removes later pages and releases their readers.

Fixed cursors do not form a transaction snapshot across pages. An insertion in page one can displace its final row before page two’s fixed cursor. That row then appears in neither page. Changes to sort values can also create overlapping results between pages. Use one growing window when the complete visible list must remain contiguous.

Each page has a fixed maximum size, but all mounted pages still consume resources. For long feeds, define an application limit or unmount pages that are no longer needed. Unmounted pages require retention management or another load when the user returns.

Index the server’s page reads

For SQLite storage, a matching index can reduce work for ordered page reads. Add it in a new SQL migration:

CREATE INDEX IF NOT EXISTS issue_updated ON issue (updatedAt DESC, id DESC);

Use the CLI query analysis tools to inspect your actual query plan. An index’s benefit depends on the filters, ordering, and data distribution.

See query shapes for limit and start, preloads for route preparation, and live aggregates for a separate live total.