Skip to content

@cfast/pagination

@cfast/pagination provides client-side React hooks that consume paginated data from React Router loaders. The server-side helpers (param parsing, query building) live in @cfast/db. This split means you pick the pagination strategy in the loader, then swap client hooks freely — the same loader data works with a “load more” button, infinite scroll, or traditional page numbers.

The package supports three patterns: cursor-based pagination (for feeds and timelines), offset-based pagination (for admin tables), and infinite scroll (for social-style feeds). Each has a loader/hook pair that handles the mechanics so you focus on rendering.

Terminal window
pnpm add @cfast/pagination

Peer dependencies: react-router

Server-side pagination helpers come from @cfast/db (installed separately).

A typical cursor-based pagination flow has a loader that parses params and paginates a query, paired with a client hook that accumulates pages:

// Loader (server)
import { parseCursorParams } from "@cfast/db";
export async function loader({ request }) {
const page = parseCursorParams(request, {
defaultLimit: 20,
maxLimit: 100,
});
const result = await db.query(posts)
.paginate(page, {
orderBy: desc(posts.createdAt),
cursorColumns: [posts.createdAt, posts.id],
})
.run({});
return result; // { items, nextCursor }
}
// Component (client)
import { usePagination } from "@cfast/pagination";
function PostList() {
const { items, loadMore, hasMore, isLoading } = usePagination<Post>();
return (
<div>
{items.map((post) => <PostCard key={post.id} post={post} />)}
{hasMore && (
<button onClick={loadMore} disabled={isLoading}>
{isLoading ? "Loading..." : "Load More"}
</button>
)}
</div>
);
}

Cursor-based pagination uses an opaque token (the cursor) to fetch the next page. This is ideal for feeds and timelines where rows may be inserted between page loads, since cursors are stable across data changes. Cursors are base64-encoded JSON containing the values of the cursorColumns for the last item — clients cannot tamper with or depend on the format.

On the server, parseCursorParams reads ?cursor=X&limit=Y from the request URL. The paginate method on the query builder applies the cursor condition and limit. The result includes items and nextCursor (null when there are no more pages).

Offset-based pagination uses traditional page numbers. This is better for admin interfaces where users need to jump to a specific page or see the total count:

// Loader
import { parseOffsetParams } from "@cfast/db";
export async function loader({ request }) {
const page = parseOffsetParams(request, { defaultLimit: 20 });
const result = await db.query(posts)
.paginate(page, { orderBy: desc(posts.createdAt) })
.run({});
return result; // { items, total, page, totalPages }
}
// Component
import { useOffsetPagination } from "@cfast/pagination";
function PostList() {
const { items, totalPages, currentPage, goToPage } = useOffsetPagination<Post>();
return (
<div>
{items.map((post) => <PostCard key={post.id} post={post} />)}
<nav>
{Array.from({ length: totalPages }, (_, i) => (
<button
key={i + 1}
onClick={() => goToPage(i + 1)}
disabled={currentPage === i + 1}
>
{i + 1}
</button>
))}
</nav>
</div>
);
}

Infinite scroll uses the same cursor-based loader as usePagination, but triggers loading automatically when a sentinel element becomes visible via IntersectionObserver:

import { useInfiniteScroll } from "@cfast/pagination";
function PostFeed() {
const { items, sentinelRef, isLoading, hasMore } = useInfiniteScroll<Post>();
return (
<div>
{items.map((post) => <PostCard key={post.id} post={post} />)}
<div ref={sentinelRef} />
{isLoading && <Spinner />}
</div>
);
}

The hook accepts a rootMargin option (default: "200px") to control how early loading triggers, and deduplicates items to handle data changes during scrolling.

Switching Between Load-More and Infinite Scroll

Section titled “Switching Between Load-More and Infinite Scroll”

Because both usePagination and useInfiniteScroll consume the same cursor-based loader data, you can swap between them without changing the server code:

// Same loader for both
export async function loader({ request }) {
const page = parseCursorParams(request, { defaultLimit: 20 });
return db.query(posts).paginate(page, { ... }).run({});
}
// Pick the UX you want on the client
const manualLoad = usePagination<Post>();
// or
const autoLoad = useInfiniteScroll<Post>();

By default, hooks use item.id to deduplicate items across pages. If your items use a different key, pass a getKey function:

const { items, loadMore, hasMore } = usePagination<Comment>({
getKey: (comment) => comment.commentId,
});

@cfast/ui’s DataTable and ListView accept pagination hook results directly, wiring up loading states, page controls, and infinite scroll automatically:

import { DataTable } from "@cfast/joy";
function PostsTable() {
const pagination = usePagination<Post>();
return <DataTable data={pagination} table={posts} columns={["title", "createdAt"]} />;
}