Angular Infinite Scroll
Angular resources are great to deal with asynchronous data like http requests.
Inspired by TanStack Query, resources enhance async operations with metadata (loading state etc.) and utilities, while turning the value of the operation into a signal which can be used inside the Angular template or be further processed with computed
signals.
Since resources allow providing a request computation which is reactive, things like paging become a breeze:
page = signal(0);
data = httpResource(() => `https://api.scryfall.com/cards/search?q=c%3Awhite+mv%3D1&page=${this.page()}`);
Each time the page signal is updated, the resource is reloaded with data from the new page.
Now what if you don't want to discard the previous value, but load additional data while keeping the previous result, like we do in an infinite scroll scenario. Currently, each time the page changes the new page is loaded and the existing data replaced.
Unfortunately you can't just access the previous value of the resource and add the new page directly.
What we can do though is adding a linkedSignal
which holds all data. Linked signals are, just like signals, writable, but they can depend on other state. They are useful in our case, because the previous value of the signal can be accessed and extended, which is not the case for computed
signals.
allPages = linkedSignal<Card[], Card[]>({
source: () => this.data.value()?.data ?? [],
computation: (source, prev) => (prev?.value ?? []).concat(source)
});
That's it, we now have all our data in the allPages
signal and can access it in our template. Further we can use the httpResource
to display a loading indicator:
<ul>
@for (item of allPages(); track item.id) {
<li #item>{{item.name}}</li>
}
</ul>
@if (data.isLoading()) {
<p>Loading...</p>
}
Now the infinite scroll is only an IntersectionObserver
away. As you can see we have added a #item
identifier to the li
. This is done, so we can get the currently last item and load the next page as soon as it's in the viewport.
observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
this.page.update(p => p + 1);
}
});
loadOnEnd = effect(cleanup => {
const el = this.cardElements().at(-1);
if (el?.nativeElement) {
this.observer.observe(el.nativeElement);
cleanup(() => {
this.observer.unobserve(el.nativeElement);
});
}
});
What is happening here? First, we create a IntersectionObserver
, which updates the page count as soon as the observed element is in the viewport. Second, we use an effect to observe the last element anytime the template changes. The cleanup is done, so we don't trigger the update more than once for the same element. That's it, that's all you need! I think it's awesome how signals simplify dependent state.
The Code
import { httpResource } from '@angular/common/http';
import { Component, effect, ElementRef, linkedSignal, signal, viewChildren } from '@angular/core';
interface Card {
name: string;
id: string;
}
// scryfall search with a query with lots of results
const baseUrl = 'https://api.scryfall.com/cards/search?q=c%3Awhite+mv%3D1&page=';
@Component({
selector: 'app-root',
template: `
<ul>
@for (card of allPages(); track card.id) {
<li #card>{{card.name}}</li>
}
</ul>
@if (data.isLoading()) {
<p>Loading...</p>
}
`,
styles: [],
})
export class App {
cardElements = viewChildren<ElementRef<HTMLLIElement>>('card');
page = signal(1);
data = httpResource<{ data: Card[] }>(
() => `${baseUrl}${this.page()}`
);
allPages = linkedSignal<Card[], Card[]>({
source: () => this.data.value()?.data ?? [],
computation: (source, prev) => (prev?.value ?? []).concat(source ?? [])
});
observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
this.page.update(p => p + 1);
}
});
loadOnEnd = effect(cleanup => {
const el = this.cardElements().at(-1);
if (el?.nativeElement) {
this.observer.observe(el.nativeElement);
cleanup(() => {
console.log('cleanup')
this.observer.unobserve(el.nativeElement);
});
}
});
}