infiniteQueryResource
Pagination that accumulates. It fetches one page at a time, keeps the pages you have already loaded, and appends the next one on request. Cursor or offset, both fit.
@mmstack/resourcenpm
A "load more" list or an infinite scroll feed is a query that grows. You don't want to refetch the whole thing each time; you want to keep pages one through three and fetch page four onto the end. infiniteQueryResource is that: a query whose result is a list of pages, with a method to fetch the next one.
Every page request inherits the full queryResource feature set, so per-page caching, retries, and circuit breakers all work the same way.
#Setting it up
Two things drive it. The request function receives a pageParam and returns the request for that page. getNextPageParam looks at the last page (and all pages so far) and returns the param for the next one, or null when there are no more. That is where cursor and offset pagination diverge, and both fit the same signature.
import { infiniteQueryResource } from '@mmstack/resource';
type PostPage = { items: Post[] };
readonly posts = infiniteQueryResource<PostPage, PostPage, number>(
({ pageParam }) => ({ url: '/api/posts', params: { page: pageParam } }),
{
initialPageParam: 0,
getNextPageParam: (last, all) =>
last.items.length < 20 ? null : all.length, // null = no more pages
cache: true,
},
); Here initialPageParam: 0 fetches page zero first, and getNextPageParam returns the count of pages loaded as the next offset until a short page signals the end. For a cursor API you would return last.nextCursor ?? null instead.
#Rendering the pages
pages() is a signal holding the array of loaded pages. Loop the pages, then the items inside each. The button reads hasNextPage() to disable itself when exhausted and isFetchingNextPage() to show a spinner while a page is on the way.
@for (page of posts.pages(); track $index) {
@for (post of page.items; track post.id) {
<p>{{ '{' }}{{ '{' }} post.title {{ '}' }}{{ '}' }}</p>
}
}
<button (click)="posts.fetchNextPage()" [disabled]="!posts.hasNextPage()">
@if (posts.isFetchingNextPage()) { Loading… } @else { Load more }
</button> If you would rather render one flat list, flatten the pages in a computed. Pairing that with keyArray from @mmstack/primitives keeps the per-item view models stable, so appending page four doesn't rebuild the rows from pages one through three.
import { keyArray } from '@mmstack/primitives';
// one flat list across every page
readonly items = computed(() => this.posts.pages().flatMap((p) => p.items));
// stable per-item view models: appending a page doesn't recreate the earlier rows
readonly rows = keyArray(this.items, (item) => buildRowVm(item), {
key: (item) => item.id,
});#Controls
Four methods and two signals cover the surface. Each does the least surprising thing.
| Member | What it does |
|---|---|
pages() | The array of loaded pages, in order. |
fetchNextPage() | Load the next page onto the end. A no-op while a page is in flight or when there are no more pages. |
hasNextPage() | false once getNextPageParam returns null. Bind a button's disabled state to it. |
isFetchingNextPage() | true while a next-page request is in flight. |
reload() | Refetch the current page in place. The result replaces its slot rather than appending a duplicate. |
reset() | Drop every page and refetch from initialPageParam. Use it when the underlying query changes, for example a new filter. |
#Pausing
The request function receives the same context as a query, plus pageParam. That means the paused token works identically: return it to hold the connection and its pages instead of tearing them down. Handy for a feed in a tab that isn't currently visible.
readonly feed = infiniteQueryResource<PostPage, PostPage, number>(
({ pageParam, paused }) =>
this.active() ? { url: '/api/feed', params: { page: pageParam } } : paused,
{ initialPageParam: 0, getNextPageParam: (last, all) => all.length },
);#Recipe: infinite scroll
Trigger fetchNextPage() when a sentinel at the bottom of the list scrolls into view. An IntersectionObserver is the plain way; the guard on hasNextPage() keeps it from firing past the end.
readonly sentinel = viewChild<ElementRef>('sentinel');
constructor() {
effect((onCleanup) => {
const el = this.sentinel()?.nativeElement;
if (!el) return;
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && this.posts.hasNextPage()) {
this.posts.fetchNextPage();
}
});
io.observe(el);
onCleanup(() => io.disconnect());
});
}