Pagination
"Load more" needs no special hook: a combined read loads the first count pages, and the component holds count.
/** The first `count` pages of notes, fetched in parallel: page numbers don't depend on each other. */
const notePages = combine(async (ctx, args: { count: number }) => {
const pages = await Promise.all(
Array.from({ length: args.count }, (_, page) =>
ctx.get(client.getNotes, { query: { page } }),
),
);
const last = pages[pages.length - 1]!;
return {
notes: pages.flatMap((page) => page.notes),
total: last.total,
hasMore: last.hasMore,
};
});
function Notes({ onOpen }: { onOpen: (id: number) => void }) {
const [count, setCount] = useState(1);
const [isPending, startTransition] = useTransition();
const list = useGet(notePages, { count });
return (
<section aria-busy={isPending}>
<h2>Your notes ({list.total})</h2>
<ul>
{list.notes.map((note) => (
<NoteRow key={note.id} note={note} onOpen={onOpen} />
))}
</ul>
{/* The next page shows up where it will land, not as a changed label. */}
{isPending && <RowsSkeleton rows={2} />}
{list.hasMore && (
<button
type="button"
onClick={() => startTransition(() => setCount(count + 1))}
disabled={isPending}
>
Show more
</button>
)}
</section>
);
}
In this example:
notePagesis pure: the samecountalways gives the same notes. The component only decides how many pages it wants.- "Show more" raises
countinsidestartTransition, so the loaded notes stay on screen andisPendingis true until the next page lands. Placeholder rows show where it will land. - Load more fetches one page. The combined read owns the pages it fetched, so pages 1 to n are still there, whatever their own cache settings say.
- Page numbers don't depend on each other, so
Promise.allfetches them in parallel, which matters when a refresh refetches every loaded page. refresh: [client.getNotes]re-runs the combined read: every loaded page is fetched again, and the list stays on screen meanwhile.
With cursors, each page's cursor comes from the page before, so the walk is sequential:
export const notePages = combine(async (ctx, args: { count: number }) => {
const pages = [];
let cursor: number | null | undefined;
while (cursor !== null && pages.length < args.count) {
const page = await ctx.get(client.getNotes, { query: { cursor } });
pages.push(page);
cursor = page.nextCursor;
}
return { notes: pages.flatMap((page) => page.notes), hasMore: cursor !== null };
});
A cursor walk survives deletions: when a note is deleted, a refresh recomputes the cursors, and nothing is skipped. Page numbers shift instead, and the refresh realigns them.
An early @cuple/react had a usePages hook, but it was a specific solution for the simplest case, and combine already does what pages need: several reads, one value, refreshed together. So pagination is a recipe with combine, not a hook of its own.
A combined read owns the pages it fetched. Without that, each page would follow its own cache settings, so the cache could drop earlier pages while the list is open, even with storeStaleMs: Infinity on the list. The next "load more" would then quietly refetch all of them.