Pagination & Infinite Queries
Fetchium supports cursor-based, offset-based, and URL-based pagination through the fetchNext configuration on queries. When fetchNext is configured, the query result exposes __fetchNext(), __hasNext, and __isFetchingNext --- giving you everything you need to build infinite scroll, "load more" buttons, and paginated lists.
How Pagination Works
Pagination in Fetchium is declarative. You define how to fetch the next page on your query class, and Fetchium handles the rest: resolving cursor values from the current response, executing the next-page request, and merging results.
The flow is:
- Initial query fetches the first page.
- The response includes pagination metadata (a cursor, an offset, or a next URL).
__fetchNext()reads the pagination metadata from the current result, constructs the next request, and fetches it.- Results are merged --- live arrays append new entities, while plain arrays are replaced.
- The pagination metadata is updated to reflect the new response, so the next
__fetchNext()call fetches the correct page.
Configuring Pagination
Add a fetchNext field to your RESTQuery class. It accepts two optional properties:
| Property | Type | Description |
|---|---|---|
url | string or FieldRef | Override the URL for the next page request |
searchParams | Record<string, unknown> | Search parameters to append. Values can be FieldRefs. |
Cursor-based pagination
The most common pattern. Your API returns a cursor in the response body, and you pass it as a search param on the next request.
import { t, Entity } from 'fetchium';
import { RESTQuery } from 'fetchium/rest';
class Item extends Entity {
__typename = t.typename('Item');
id = t.id;
name = t.string;
}
class GetItems extends RESTQuery {
path = '/items';
result = {
items: t.liveArray(Item),
nextCursor: t.optional(t.string),
};
fetchNext = {
searchParams: {
cursor: this.result.nextCursor,
},
};
}
The value this.result.nextCursor is a field reference (FieldRef). At runtime, when __fetchNext() is called, Fetchium resolves the FieldRef against the current result data. If the first response returned { nextCursor: 'abc123' }, the next request will include ?cursor=abc123.
Offset-based pagination
For APIs that use page numbers or offsets:
class GetItems extends RESTQuery {
path = '/items';
result = {
items: t.array(t.string),
nextPage: t.optional(t.number),
limit: t.number,
};
fetchNext = {
searchParams: {
page: this.result.nextPage,
limit: this.result.limit,
},
};
}
Multiple FieldRefs can be used in the same searchParams object. Each is resolved independently against the current result data.
URL-based pagination
Some APIs return a full URL for the next page. Use the url property instead of searchParams:
class GetItems extends RESTQuery {
path = '/items';
result = {
items: t.array(t.string),
nextUrl: t.optional(t.string),
};
fetchNext = {
url: this.result.nextUrl,
};
}
When __fetchNext() is called, Fetchium fetches the resolved URL directly instead of constructing one from the original path and search params.
Dynamic Pagination with getFetchNext()
For cases where the pagination logic depends on runtime conditions --- response headers, error codes, or computed values --- override getFetchNext() instead of using the static fetchNext field.
class GetItems extends RESTQuery {
path = '/items';
result = { items: t.array(t.string), total: t.number };
getFetchNext() {
// Use a page token from response headers if available
const pageToken = this.response?.headers?.get?.('X-Next-Page-Token');
if (pageToken) {
return { searchParams: { pageToken } };
}
// Fall back to offset-based pagination
return { searchParams: { offset: 1 } };
}
}
getFetchNext() has access to this.response (the raw Response object from the previous fetch) and this.params (the query params). It should return a FetchNextConfig object or undefined.
Return undefined to disable pagination
If getFetchNext() returns undefined, __hasNext will be false and __fetchNext() will throw. Use this to conditionally disable pagination:
class GetItems extends RESTQuery {
path = '/items';
result = {
items: t.array(t.string),
hasMore: t.boolean,
nextPage: t.optional(t.number),
};
getFetchNext() {
// Only allow pagination on successful responses
if (this.response?.status !== 200) {
return undefined;
}
return { searchParams: { page: 2 } };
}
}
Priority: getFetchNext() overrides fetchNext
When both a static fetchNext field and a getFetchNext() method are defined, the method takes priority. The static field is ignored.
Using Pagination in Components
import { useQuery } from 'fetchium/react';
function ItemList() {
const query = useQuery(GetItems);
if (query.isPending) return <div>Loading...</div>;
const result = query.value;
return (
<div>
<ul>
{result.items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
{result.__hasNext && (
<button
onClick={() => result.__fetchNext()}
disabled={result.__isFetchingNext}
>
{result.__isFetchingNext ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
Headless usage (outside React)
import { fetchQuery } from 'fetchium';
const relay = fetchQuery(GetItems);
await relay;
console.log(relay.value.items); // First page items
if (relay.value.__hasNext) {
await relay.value.__fetchNext();
console.log(relay.value.items); // Updated items (appended for live arrays)
}
QueryResult Pagination Properties
When fetchNext is configured on a query, the query result object gains three additional properties:
| Property | Type | Description |
|---|---|---|
__fetchNext() | () => Promise | Fetches the next page. Returns a promise that resolves when the page is loaded. |
__hasNext | boolean | Whether more pages are available. |
__isFetchingNext | boolean | Whether a next-page request is currently in flight. |
All three properties are reactive --- reading them inside a Signalium reactive function or a component() establishes a dependency, so your UI updates automatically when the values change.
How __hasNext is determined
For static fetchNext (with FieldRefs), __hasNext is true when all FieldRef values in the searchParams (or url) resolve to non-null, non-undefined values. When the API returns nextCursor: undefined or nextCursor: null, __hasNext becomes false.
For getFetchNext(), __hasNext is true when the method returns a non-undefined config object, and false when it returns undefined.
Deduplication of concurrent calls
Calling __fetchNext() multiple times concurrently returns the same promise. Only one network request is made per page --- subsequent calls while a request is in flight are deduplicated.
// These are the same promise --- only one request is made
const p1 = result.__fetchNext();
const p2 = result.__fetchNext();
p1 === p2; // true
Append Mode vs Replace Mode
The behavior when a new page is loaded depends on the type of array in your result:
Live arrays (t.liveArray) --- append mode
New entities from the next page are appended to the existing array. The array accumulates across pages, giving you the classic infinite scroll behavior. Duplicate entities (same typename + id) are deduplicated --- the existing entry is updated in place rather than added again.
class GetItems extends RESTQuery {
path = '/items';
result = {
items: t.liveArray(Item), // Entities accumulate across pages
nextCursor: t.optional(t.string),
};
fetchNext = {
searchParams: { cursor: this.result.nextCursor },
};
}
After loading three pages with 2 items each, result.items contains all 6 items (assuming no duplicates).
Plain arrays (t.array) --- replace mode
Plain arrays are replaced with the new page's data. The previous page's items are discarded.
class GetItems extends RESTQuery {
path = '/items';
result = {
items: t.array(t.string), // Replaced on each fetchNext
nextPage: t.optional(t.number),
};
fetchNext = {
searchParams: { page: this.result.nextPage },
};
}
After loading a new page, result.items contains only the new page's items.
Choose the right array type
Use t.liveArray when you want infinite scroll or "load more" behavior where items accumulate. Use t.array when you want traditional pagination where each page replaces the previous one (e.g. a paginated table with "Previous / Next" buttons).
Scalar Field Updates
Non-array fields in the result are always updated to the new page's values. This is how cursor advancement works:
result = {
items: t.liveArray(Item),
nextCursor: t.optional(t.string), // Updated to new cursor on each page
totalCount: t.number, // Updated to new value on each page
};
After loading the next page:
nextCursoris updated to the new cursor value (orundefined/nullon the last page).totalCountreflects whatever the new response returned.
If the new response omits an optional field entirely, it becomes undefined. If it explicitly sends null for a nullable field, it becomes null.
Cursor Advancement
FieldRefs automatically resolve to the current result data at the time __fetchNext() is called. This means cursors advance naturally across multiple pages:
class GetItems extends RESTQuery {
path = '/items';
result = {
items: t.liveArray(Item),
cursor: t.optional(t.string),
};
fetchNext = {
searchParams: { cursor: this.result.cursor },
};
}
- Initial fetch returns
{ cursor: 'c1' }. - First
__fetchNext()sends?cursor=c1, response returns{ cursor: 'c2' }. - Second
__fetchNext()sends?cursor=c2, response returns{ cursor: 'c3' }. - Third
__fetchNext()sends?cursor=c3, response returns{ cursor: undefined }. __hasNextbecomesfalse. No more pages.
You do not need to manually track or update the cursor --- Fetchium handles it automatically through the FieldRef resolution mechanism.
Error Handling
If a __fetchNext() request fails (network error, server error, etc.), the promise rejects and the existing data is preserved. The array is not corrupted, and the cursor is not advanced.
try {
await result.__fetchNext();
} catch (error) {
// The error is from the failed fetch
console.error('Failed to fetch next page:', error);
// result.items still contains the previous pages' data
// result.__hasNext is still true (cursor was not advanced)
}
This means you can safely retry by calling __fetchNext() again after a failure --- it will use the same cursor value since the result data was not updated.
Edge Cases
Calling __fetchNext() before initial data loads
If you call __fetchNext() before the initial query has resolved, it throws an error:
Cannot call __fetchNext before initial data has loaded
Always check query.isReady or query.isPending before accessing __fetchNext().
Calling __fetchNext() without pagination configured
If neither fetchNext nor getFetchNext() is defined on the query class, calling __fetchNext() throws:
fetchNext is not configured
In this case, __hasNext is always false and __isFetchingNext is always false.
Combining fetchNext with searchParams
When fetchNext provides additional searchParams, they are merged with the query's base search params (from the searchParams field or getSearchParams() method). The fetchNext params take priority for any overlapping keys.
Complete Example
Here is a full example showing cursor-based pagination with a live array, entity normalization, and a "load more" UI:
import { Entity, t } from 'fetchium';
import { RESTQuery } from 'fetchium/rest';
import { useQuery } from 'fetchium/react';
// Entity definition
class Post extends Entity {
__typename = t.typename('Post');
id = t.id;
title = t.string;
body = t.string;
author = t.entity(User);
createdAt = t.format('date-time');
}
// Query with pagination
class GetPosts extends RESTQuery {
params = { userId: t.number };
path = `/users/${this.params.userId}/posts`;
result = {
posts: t.liveArray(Post),
nextCursor: t.nullish(t.string),
};
fetchNext = {
searchParams: {
cursor: this.result.nextCursor,
},
};
config = {
staleTime: 30_000,
};
}
// React component
function UserPosts({ userId }: { userId: number }) {
const query = useQuery(GetPosts, { userId });
if (query.isPending) return <div>Loading posts...</div>;
if (query.isRejected) return <div>Error: {query.error.message}</div>;
const { posts, __hasNext, __isFetchingNext, __fetchNext } = query.value;
return (
<div>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.body}</p>
<span>By {post.author.name}</span>
</article>
))}
{__hasNext && (
<button onClick={() => __fetchNext()} disabled={__isFetchingNext}>
{__isFetchingNext ? 'Loading...' : 'Load More Posts'}
</button>
)}
{!__hasNext && posts.length > 0 && <p>You have reached the end.</p>}
</div>
);
}