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
- Query shapes —
limit,start, and the ordered-take semantics. - Preload & navigate — warming the next route’s page query before the click.
- The browser client — the warm window that makes the growing-limit swap instant.
- Live counts & aggregates — pairing a paged list with a live total
(
count()), so “1–50 of 1,283” stays exact.