Infinite Queries
Load more data as the user scrolls with usePages
usePages is designed for infinite scroll interfaces where new data appends to an ever-growing list as the user scrolls down.
For complete API reference, see usePages.
How It Works
usePages doesn't assume any specific response shape — you tell it how to read your API's response via callback functions. The nextPageRequest function returns only the fields that change between pages. These are automatically merged with the original request, so you don't need to spread existing query parameters.
Given an API that returns:
{
"items": [
{ "id": 1, "title": "Post 1" },
{ "id": 2, "title": "Post 2" }
],
"meta": { "page": 1, "hasMore": true }
}You map those fields in the options:
const { data, canFetchNext, fetchNext, fetchingNext } = usePages(
(api) => api("posts").GET({ query: { page: 1, limit: 20 } }),
{
canFetchNext: ({ lastPage }) => lastPage?.data?.meta.hasMore ?? false,
nextPageRequest: ({ lastPage }) => ({
query: { page: (lastPage?.data?.meta.page ?? 0) + 1 },
}),
merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
}
);Since the original request already has { page: 1, limit: 20 }, returning { query: { page: 2 } } from nextPageRequest merges into { page: 2, limit: 20 } automatically.
The merger Function
As pages load, each page is collected into an array. The merger function receives all pages and combines them into a single flat list that data returns:
// After loading 3 pages, pages contains:
// [
// { status: "success", data: { items: [{id:1}, {id:2}], meta: {page:1, hasMore:true} } },
// { status: "success", data: { items: [{id:3}, {id:4}], meta: {page:2, hasMore:true} } },
// { status: "success", data: { items: [{id:5}], meta: {page:3, hasMore:false} } },
// ]
merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
// data → [{id:1}, {id:2}, {id:3}, {id:4}, {id:5}]If you need access to the raw pages (e.g., for page metadata or per-page status), use pages from the hook return:
const { data, pages } = usePages(...);
// data → merged flat list from merger()
// pages → array of page objects with status, data, error, and metaBasic Setup
function PostFeed() {
const { data, loading, canFetchNext, fetchNext, fetchingNext } =
usePages(
(api) => api("posts").GET({ query: { page: 1, limit: 20 } }),
{
canFetchNext: ({ lastPage }) => lastPage?.data?.meta.hasMore ?? false,
nextPageRequest: ({ lastPage }) => ({
query: { page: (lastPage?.data?.meta.page ?? 0) + 1 },
}),
merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
}
);
if (loading) return <div>Loading...</div>;
return (
<div>
{data?.map((post) => <PostCard key={post.id} post={post} />)}
{canFetchNext && (
<button onClick={fetchNext} disabled={fetchingNext}>
{fetchingNext ? "Loading..." : "Load More"}
</button>
)}
</div>
);
}Auto-Loading with Intersection Observer
Replace the "Load More" button with automatic loading when the user scrolls near the bottom:
import { useRef, useEffect } from "react";
function InfinitePostFeed() {
const sentinelRef = useRef<HTMLDivElement>(null);
const { data, loading, canFetchNext, fetchNext, fetchingNext } =
usePages(
(api) => api("posts").GET({ query: { page: 1, limit: 20 } }),
{
canFetchNext: ({ lastPage }) => lastPage?.data?.meta.hasMore ?? false,
nextPageRequest: ({ lastPage }) => ({
query: { page: (lastPage?.data?.meta.page ?? 0) + 1 },
}),
merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
}
);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && canFetchNext && !fetchingNext) {
fetchNext();
}
},
{ threshold: 0.1 }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [canFetchNext, fetchingNext, fetchNext]);
if (loading) return <div>Loading...</div>;
return (
<div>
{data?.map((post) => <PostCard key={post.id} post={post} />)}
<div ref={sentinelRef}>
{fetchingNext && <div>Loading more...</div>}
</div>
</div>
);
}Cursor-Based Scrolling
For APIs that return a cursor instead of page numbers:
{
"items": [{ "id": 10, "title": "Post 10" }],
"nextCursor": "abc123"
}const { data, canFetchNext, fetchNext, fetchingNext } = usePages(
(api) => api("feed").GET({ query: { limit: 20 } }),
{
canFetchNext: ({ lastPage }) => !!lastPage?.data?.nextCursor,
nextPageRequest: ({ lastPage }) => ({
query: { cursor: lastPage?.data?.nextCursor },
}),
merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
}
);Bidirectional Scrolling
Load content in both directions — useful for chat threads or timelines where the user starts in the middle:
const {
data,
canFetchNext,
canFetchPrev,
fetchNext,
fetchPrev,
fetchingNext,
fetchingPrev,
} = usePages((api) => api("messages").GET({ query: { page: 5, limit: 20 } }), {
canFetchNext: ({ lastPage }) => lastPage?.data?.meta.hasMore ?? false,
nextPageRequest: ({ lastPage }) => ({
query: { page: (lastPage?.data?.meta.page ?? 0) + 1 },
}),
canFetchPrev: ({ firstPage }) => (firstPage?.data?.meta.page ?? 1) > 1,
prevPageRequest: ({ firstPage }) => ({
query: { page: (firstPage?.data?.meta.page ?? 2) - 1 },
}),
merger: (pages) => pages.flatMap((p) => p.data?.messages ?? []),
});function MessageThread() {
return (
<div>
{canFetchPrev && (
<button onClick={fetchPrev} disabled={fetchingPrev}>
Load Older
</button>
)}
{data?.map((msg) => <Message key={msg.id} message={msg} />)}
{canFetchNext && (
<button onClick={fetchNext} disabled={fetchingNext}>
Load Newer
</button>
)}
</div>
);
}Combining with Plugins
usePages works with plugins like cache and deduplication. Each page request has its own cache key, and the merged list is derived from these cached responses. Deduplication prevents duplicate requests when scrolling quickly:
const spoosh = new Spoosh<ApiSchema, Error>("/api").use([
cachePlugin({ staleTime: 5000 }),
deduplicationPlugin(),
invalidationPlugin(),
]);When a mutation invalidates tags that match infinite scroll queries, all loaded pages are refetched automatically. When returning to a previously loaded query (e.g., same parameters), Spoosh reuses cached page responses and rebuilds the merged list automatically.
Trigger Mode
Use the trigger function to start a new fetch with different request options. This is useful for search or filter functionality:
function SearchablePosts() {
const [search, setSearch] = useState("");
const { data, trigger, loading } = usePages(
(api) => api("posts").GET({ query: { page: 1, limit: 20 } }),
{
enabled: false, // Don't fetch on mount - use trigger instead
canFetchNext: ({ lastPage }) => lastPage?.data?.meta.hasMore ?? false,
nextPageRequest: ({ lastPage }) => ({
query: { page: (lastPage?.data?.meta.page ?? 0) + 1 },
}),
merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
}
);
const handleSearch = () => {
// Pass new query params to trigger
// force: false uses cache if same search was done before
trigger({ query: { search, page: 1, limit: 20 }, force: false });
};
return (
<div>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
<button onClick={handleSearch}>Search</button>
{data?.map((post) => <PostCard key={post.id} post={post} />)}
</div>
);
}Trigger Options
| Option | Type | Default | Description |
|---|---|---|---|
query | object | - | Query parameters to merge |
params | object | - | Path parameters to merge |
body | unknown | - | Request body |
force | boolean | true | Delete all endpoint caches (including other search variations) and fetch fresh |
// Force refetch (default) - deletes all caches for this endpoint
// This includes caches from different search terms or query params
trigger();
// Use cache if available - keeps existing page caches
trigger({ force: false });
// New search with custom params
trigger({ query: { search: "react", category: "tech" } });Reactive State Changes
Like useRead, usePages reacts to changes in the request function. When query parameters change, it automatically fetches with the new parameters:
function FilteredPosts({ category }: { category: string }) {
const { data, loading, canFetchNext, fetchNext } = usePages(
// When category changes, this triggers a new fetch automatically
(api) => api("posts").GET({ query: { page: 1, category } }),
{
canFetchNext: ({ lastPage }) => lastPage?.data?.meta.hasMore ?? false,
nextPageRequest: ({ lastPage }) => ({
query: { page: (lastPage?.data?.meta.page ?? 0) + 1 },
}),
merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
}
);
return (
<div>
{data?.map((post) => <PostCard key={post.id} post={post} />)}
{canFetchNext && <button onClick={fetchNext}>Load More</button>}
</div>
);
}Reactive vs Trigger Mode
Choose based on your use case:
| Approach | When to Use |
|---|---|
| Reactive | Filter buttons, dropdowns, URL-driven filters |
| Trigger | Search inputs, manual refresh, form submissions |
// Reactive: category from props/state
const { data } = usePages(
(api) => api("posts").GET({ query: { page: 1, category } }),
{
/* options */
}
);
// Trigger: manual search (force: false to use cache if available)
const { data, trigger } = usePages(
(api) => api("posts").GET({ query: { page: 1 } }),
{ enabled: false /* options */ }
);
trigger({ query: { search: searchTerm }, force: false });Refetching All Pages
Use the trigger function without options to reset to the initial request and refetch from the beginning:
const { data, trigger } = usePages(
(api) => api("posts").GET({ query: { page: 1, limit: 20 } }),
{
canFetchNext: ({ lastPage }) => lastPage?.data?.meta.hasMore ?? false,
nextPageRequest: ({ lastPage }) => ({
query: { page: (lastPage?.data?.meta.page ?? 0) + 1 },
}),
merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
}
);
// Reset to first page and refetch
<button onClick={() => trigger()}>Refresh All</button>