Recipes

Pagination & infinite scroll

Two live paging shapes and when each is right: grow one query's `limit` for a seamless always-exact window (the default), or cut stable keyset pages with `.start(cursor, { exclusive: true })` when page identity should never change. Plus the tiebreak rule that makes cursors deterministic, and the index that keeps page fetches cheap.

View as Markdown

Paging in Rindle is not “fetch page 2” — every page is a live query, incrementally maintained. So rows inserted or deleted anywhere move through your pages in real time. That changes which pattern you want. There are two, and the first is the right default:

  • Grow one query’s limit — one window that is always exactly the top N. Simplest, and correct by construction.
  • Keyset pages with .start — one stable query per page. More machinery, but page identity never changes, which matters for very long feeds.

Order totally, or cursors lie

Both patterns depend on the sort being a total order. The engine internally appends the primary key to every orderBy so its own maintenance is deterministic, but your cursor only contains the columns you name. So name the tiebreak explicitly, in the same direction, and carry it in the cursor:

q.issue
  .orderBy("updatedAt", "desc")
  .orderBy("id", "desc")        // the tiebreak — two rows sharing updatedAt still order the same way everywhere

Without it, two rows with equal updatedAt straddle a cursor ambiguously, and a page boundary can skip or double a row. With it, a cursor is just the last row’s { updatedAt, id }.

The default: grow the limit

One named query whose limit is an argument. “Load more” bumps it:

// src/components/IssueList.queries.ts
const pageArgs = z.object({ limit: z.number().int().min(1).max(500) });

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

function IssueList() {
  const [limit, setLimit] = useState(PAGE);
  const rows = useQuery(issuesPage({ limit }));
  return (
    <>
      <ul>{rows.map((r) => <li key={r.id}>{r.title}</li>)}</ul>
      {rows.length === limit && (
        <button onClick={() => setLimit(limit + PAGE)}>Load more</button>
      )}
    </>
  );
}

Each bump is technically a new query (limit: 50 and limit: 100 are different ASTs), but the swap has no visible gap: the replacement re-materializes instantly from the still-warm local base while its server lease streams the extension. This is exactly what the 2-second warm window exists for. Don’t set releaseDelayMs: 0 here. The handoff is the feature.

What you get is one window that is always exactly the top limit rows: a row inserted at position 3 appears at position 3 and the last row slides out. No seams, no drift. The cost is that one view maintains the whole window. So this is the pattern for lists a human actually scrolls — hundreds to a few thousand rows — not an unbounded feed.

For infinite scroll, replace the button with an IntersectionObserver on a sentinel <li> that calls the same setLimit.

Stable pages: keyset cursors

When a feed is effectively unbounded, cut it into pages that each keep their identity. A page you scrolled past stays materialized and is never re-fetched when you scroll back:

const afterArgs = z.object({
  cursor: z.object({ updatedAt: z.number(), id: z.string() }).nullable(),
});

export const issuesAfter = defineQuery("issuesAfter", (raw) => afterArgs.parse(raw), ({ cursor }) => {
  const base = q.issue
    .select("id", "title", "status", "updatedAt")
    .orderBy("updatedAt", "desc")
    .orderBy("id", "desc")
    .limit(50);
  // First page has no bound; later pages start strictly after the previous page's last row.
  return cursor ? base.start(cursor, { exclusive: true }) : base;
});
type Cursor = { updatedAt: number; id: string } | null;

function Feed() {
  const [cursors, setCursors] = useState<Cursor[]>([null]);   // one entry per mounted page
  return (
    <>
      {cursors.map((cursor, i) => (
        <FeedPage
          key={cursor ? cursor.id : "first"}
          cursor={cursor}
          onFilled={(last) =>
            // Extend only from the LAST page, once, when its sentinel scrolls into view.
            i === cursors.length - 1 && setCursors((cs) => [...cs, last])
          }
        />
      ))}
    </>
  );
}

function FeedPage({ cursor, onFilled }: { cursor: Cursor; onFilled: (last: Cursor) => void }) {
  const rows = useQuery(issuesAfter({ cursor }));
  const last = rows.at(-1);
  return (
    <>
      <ul>{rows.map((r) => <li key={r.id}>{r.title}</li>)}</ul>
      {rows.length === 50 && last && (
        <Sentinel onVisible={() => onFilled({ updatedAt: last.updatedAt, id: last.id })} />
      )}
    </>
  );
}

The honest tradeoff: the seams are frozen. Each page is live individually, but the boundary between pages is the cursor you captured when the next page mounted. A row inserted inside page one’s range pushes its last row out of limit: 50. That displaced row now falls in the gap before page two’s fixed start. For an activity feed that’s invisible. For a ranked list it isn’t. When exactness across the whole visible set matters, that’s the growing-limit pattern’s job.

Keep the page fetch indexed

A page fetch is an ordered seek. Give SQLite an index on the sort prefix so limit/start seek instead of scan, and check it with the CLI:

CREATE INDEX IF NOT EXISTS issue_updated ON issue (updatedAt DESC, id DESC);
rindle analyze query --local     # pick the query; look for the index-served seek, not a scan

(The engine finishes the pk tiebreak in-engine when your index covers only the prefix — an index on (updatedAt) alone still avoids the full sort. But matching the full sort is the simple, fast default.) See the CLI for analyze query.

See also