Infinite Queries
Load more data as the user scrolls with injectPages
injectPages is designed for infinite scroll interfaces where new data appends to an ever-growing list as the user scrolls down. All return values are Angular Signals.
For complete API reference, see injectPages.
How It Works
injectPages 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:
private posts = injectPages(
(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 inject return:
private posts = injectPages(...);
data = this.posts.data; // merged flat list from merger()
pages = this.posts.pages; // array of page objects with status, data, error, and metaBasic Setup
@Component({
selector: "app-post-feed",
template: `
@if (loading()) {
<div>Loading...</div>
} @else {
@for (post of data(); track post.id) {
<app-post-card [post]="post" />
}
@if (canFetchNext()) {
<button (click)="fetchNext()" [disabled]="fetchingNext()">
{{ fetchingNext() ? "Loading..." : "Load More" }}
</button>
}
}
`,
})
export class PostFeedComponent {
private posts = injectPages(
(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 ?? []),
}
);
data = this.posts.data;
loading = this.posts.loading;
canFetchNext = this.posts.canFetchNext;
fetchNext = this.posts.fetchNext;
fetchingNext = this.posts.fetchingNext;
}Auto-Loading with Intersection Observer
Replace the "Load More" button with automatic loading when the user scrolls near the bottom:
@Component({
selector: "app-infinite-post-feed",
template: `
@if (loading()) {
<div>Loading...</div>
} @else {
@for (post of data(); track post.id) {
<app-post-card [post]="post" />
}
<div #sentinel>
@if (fetchingNext()) {
<div>Loading more...</div>
}
</div>
}
`,
})
export class InfinitePostFeedComponent implements AfterViewInit, OnDestroy {
@ViewChild("sentinel") sentinelRef!: ElementRef<HTMLDivElement>;
private observer: IntersectionObserver | null = null;
private posts = injectPages(
(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 ?? []),
}
);
data = this.posts.data;
loading = this.posts.loading;
fetchingNext = this.posts.fetchingNext;
ngAfterViewInit() {
this.observer = new IntersectionObserver(
(entries) => {
if (
entries[0].isIntersecting &&
this.posts.canFetchNext() &&
!this.posts.fetchingNext()
) {
this.posts.fetchNext();
}
},
{ threshold: 0.1 }
);
this.observer.observe(this.sentinelRef.nativeElement);
}
ngOnDestroy() {
this.observer?.disconnect();
}
}Cursor-Based Scrolling
For APIs that return a cursor instead of page numbers:
{
"items": [{ "id": 10, "title": "Post 10" }],
"nextCursor": "abc123"
}private feed = injectPages(
(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:
@Component({
selector: "app-message-thread",
template: `
@if (canFetchPrev()) {
<button (click)="fetchPrev()" [disabled]="fetchingPrev()">
Load Older
</button>
}
@for (msg of data(); track msg.id) {
<app-message [message]="msg" />
}
@if (canFetchNext()) {
<button (click)="fetchNext()" [disabled]="fetchingNext()">
Load Newer
</button>
}
`,
})
export class MessageThreadComponent {
private messages = injectPages(
(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 ?? []),
}
);
data = this.messages.data;
canFetchNext = this.messages.canFetchNext;
canFetchPrev = this.messages.canFetchPrev;
fetchNext = this.messages.fetchNext;
fetchPrev = this.messages.fetchPrev;
fetchingNext = this.messages.fetchingNext;
fetchingPrev = this.messages.fetchingPrev;
}Combining with Plugins
injectPages 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:
@Component({
selector: "app-searchable-posts",
template: `
<input [ngModel]="search()" (ngModelChange)="search.set($event)" />
<button (click)="handleSearch()">Search</button>
@for (post of data(); track post.id) {
<app-post-card [post]="post" />
}
`,
})
export class SearchablePostsComponent {
search = signal("");
private posts = injectPages(
(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 ?? []),
}
);
data = this.posts.data;
handleSearch() {
// Pass new query params to trigger
// force: false uses cache if same search was done before
this.posts.trigger({
query: { search: this.search(), page: 1, limit: 20 },
force: false,
});
}
}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
this.posts.trigger();
// Use cache if available - keeps existing page caches
this.posts.trigger({ force: false });
// New search with custom params
this.posts.trigger({ query: { search: "angular", category: "tech" } });Reactive State Changes
Like injectRead, injectPages reacts to changes in the request function. When query parameters change via signals, it automatically fetches with the new parameters:
@Component({
selector: "app-filtered-posts",
template: `
@for (post of data(); track post.id) {
<app-post-card [post]="post" />
}
@if (canFetchNext()) {
<button (click)="fetchNext()">Load More</button>
}
`,
})
export class FilteredPostsComponent {
// Input signal that changes externally
category = input.required<string>();
private posts = injectPages(
// When category() changes, this triggers a new fetch automatically
(api) =>
api("posts").GET({ query: { page: 1, category: this.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 ?? []),
}
);
data = this.posts.data;
canFetchNext = this.posts.canFetchNext;
fetchNext = this.posts.fetchNext;
}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 input signal
private posts = injectPages(
(api) => api("posts").GET({ query: { page: 1, category: this.category() } }),
{ /* options */ }
);
// Trigger: manual search (force: false to use cache if available)
private posts = injectPages(
(api) => api("posts").GET({ query: { page: 1 } }),
{ enabled: false, /* options */ }
);
this.posts.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:
@Component({
selector: "app-post-feed",
template: `
<button (click)="refresh()">Refresh All</button>
@for (post of data(); track post.id) {
<app-post-card [post]="post" />
}
`,
})
export class PostFeedComponent {
private posts = injectPages(
(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 ?? []),
}
);
data = this.posts.data;
// Reset to first page and refetch
refresh() {
this.posts.trigger();
}
}