Infinite scrolling is a feature where more content loads as you get near the end of a list. It's popular in many apps and websites. I often use the Infinite Scroll component from Ionic (ion-infinite-scroll), which is very handy. When you're close to the end of the list, it triggers an event called ionInfinite
. This event can start loading more data. Once the data is loaded, you can stop the spinner and get ready to load more data by using event.target.complete()
.
However, you need to write your own code to load data in parts (pagination) and handle errors. This includes keeping track of which page you're on, adding new data to the existing list, and keeping an eye on the loading and error states to avoid loading data twice or missing errors.
Often, this logic ends up in the same component that displays the growing list of items. This can make things unnecessarily complicated. That's why I use a custom directive called InfiniteScrollStateDirective
. This directive keeps track of pages, the total list of items, errors, loading states, and more. It takes a load function as input and has a method called loadNextPage()
. So, you just need to provide the data loading method. The directive also has outputs like itemsChange
, loadingError
, and loadingComplete
so the parent component can react and display items in the template.
Here's a basic example:
<ion-content>
<ion-list
#infiniteScrollState="appInfiniteScrollState"
appInfiniteScrollState
[loadFn]="getPokemon"
[(items)]="items"
(loadingComplete)="infiniteScroll.complete()"
>
@for (item of items; track item.url) {
<ion-item>
<ion-label>{{ item.name }}</ion-label>
</ion-item>
}
</ion-list>
<ion-infinite-scroll (ionInfinite)="infiniteScrollState.loadNextPage()" #infiniteScroll>
<ion-infinite-scroll-content></ion-infinite-scroll-content>
</ion-infinite-scroll>
</ion-content>
@Component({
selector: "app-pokemon-list",
templateUrl: "pokemon-list.page.html",
styleUrls: ["pokemon-list.page.scss"],
standalone: true,
imports: [InfiniteScrollStateDirective],
})
export class PokemonListPage {
items: PokemonListItem[] = [];
private pokemonService = inject(PokeService);
getPokemon = (page: number) => this.pokemonService.getPokemon(page);
}
In the template, I create references to #infiniteScroll
and #infiniteScrollState
. This allows easy calls to (loadingComplete)="infiniteScroll.complete()"
and (ionInfinite)="infiniteScrollState.loadNextPage()"
. The directive also uses two-way binding on [(items)]
to keep the items in the parent component up-to-date. It concatenates new data with the existing items, so I don't need to write this logic in the component.
Let's take a guided tour through the implementation of the InfiniteScrollStateDirective
I wrote. This directive makes infinite scrolling simpler in Angular. We'll break down the code into small parts and explain each one in simple English.
First, let's look at the core inputs and outputs of the directive:
@Input() loadFn!: (page: number) => Observable<T[]>;
@Input() page: number = 0;
@Output() itemsChange = new EventEmitter<T[]>();
@Output() loadingError = new EventEmitter<Error>();
@Output() loadingChange = new EventEmitter<boolean>();
@Output() loadingComplete = new EventEmitter<void>();
loadFn: This is a required input. It's a function that you must provide. This function is called with a page number to fetch data. It returns an Observable stream of data.
page: This property keeps track of which page you are currently on. It starts from 0.
itemsChange: This output lets the parent component know when new items are added.
loadingError: If there's an error while loading data, this output sends an error message to the parent component.
loadingChange: It informs about the loading state – whether data is being loaded or not.
loadingComplete: This is used to signal when data loading is complete.
Now, let's see what happens when the directive starts (OnInit):
ngOnInit(): void {
this.loadNextPage();
}
When the directive is initialized, it immediately calls loadNextPage()
. This function starts the process of fetching data for the first page.
The loadNextPage
method is a key part of our directive:
loadNextPage(): void {
if (this.isLoading) {
return;
}
this.isLoading = true;
this.loadingChange.emit(this.isLoading);
this.loadFn(this.page)
.pipe(takeUntil(this.componentDestroyed$))
.subscribe({
next: (items) => this.handleNewItems(items),
error: (error) => this.handleError(error),
complete: () => this.handleLoadComplete(),
});
}
In loadNextPage
, we first check if data is already being loaded. If it is, we don't do anything. If not, we set isLoading
to true
and start loading data for the current page. We use the loadFn
provided by the user of this directive. We handle the new items, any errors, and the completion of the load process in separate functions.
The handleNewItems
method is used to update the list of items:
private handleNewItems(items: T[]): void {
this.items = [...this.items, ...items];
this.itemsChange.emit(this.items);
this.page++;
this.pageChange.emit(this.page);
}
Every time new items are loaded, we add them to the existing list. We then increase the page number by one, so next time we load the next page's data.
If there's an error, we handle it like this:
private handleError(error: Error): void {
this.loadingError.emit(error);
this.isLoading = false;
this.loadingChange.emit(this.isLoading);
}
We send the error to the parent component and update the loading state.
Finally, when data loading is complete:
private handleLoadComplete(): void {
this.isLoading = false;
this.loadingChange.emit(this.isLoading);
this.loadingComplete.emit();
}
We update the loading state and notify that the loading is complete.
With these pieces, the directive manages the loading of data, tracks the current page, updates the list of items, and communicates with the parent component. This keeps the code clean and separates concerns effectively. The parent component can use loadNextPage
to fetch the next page of data without worrying about the internal state of items and pages. It also can react to various events like complete loading, errors, etc.
And here is the complete implementation of the InfiniteScrollStateDirective
:
import { Directive, EventEmitter, Input, Output, OnDestroy, OnInit } from "@angular/core";
import { Observable, Subject, takeUntil } from "rxjs";
@Directive({
selector: "[appInfiniteScrollState]",
exportAs: "appInfiniteScrollState",
standalone: true,
})
export class InfiniteScrollStateDirective<T = unknown> implements OnInit, OnDestroy {
@Input() loadFn!: (page: number) => Observable<T[]>;
@Input() page: number = 0;
@Input() items: T[] = [];
@Output() itemsChange = new EventEmitter<T[]>();
@Output() loadingError = new EventEmitter<Error>();
@Output() loadingChange = new EventEmitter<boolean>();
@Output() loadingComplete = new EventEmitter<void>();
@Output() pageChange = new EventEmitter<number>();
private componentDestroyed$ = new Subject<void>();
private isLoading = false;
ngOnInit(): void {
this.loadNextPage();
}
ngOnDestroy(): void {
this.componentDestroyed$.next();
this.componentDestroyed$.complete();
}
loadNextPage(): void {
if (this.isLoading) {
return;
}
this.isLoading = true;
this.loadingChange.emit(this.isLoading);
this.loadFn(this.page)
.pipe(takeUntil(this.componentDestroyed$))
.subscribe({
next: (items) => this.handleNewItems(items),
error: (error) => this.handleError(error),
complete: () => this.handleLoadComplete(),
});
}
private handleNewItems(items: T[]): void {
this.items = [...this.items, ...items];
this.itemsChange.emit(this.items);
this.page++;
this.pageChange.emit(this.page);
}
private handleError(error: Error): void {
this.loadingError.emit(error);
this.isLoading = false;
this.loadingChange.emit(this.isLoading);
}
private handleLoadComplete(): void {
this.isLoading = false;
this.loadingChange.emit(this.isLoading);
this.loadingComplete.emit();
}
reset(): void {
this.page = 0;
this.items = [];
this.itemsChange.emit(this.items);
this.pageChange.emit(this.page);
}
}
Conclusion:
As you've seen, all the logic related to infinite scrolling is neatly encapsulated in the reusable InfiniteScrollStateDirective
, keeping the parent component clean. It now only needs to have an items
property and a loadFn
. In the template, you need to pay attention to correctly "connect" the ion-infinite
and the directive, as shown in the example. Ultimately, this approach provides a much cleaner and more pleasant experience compared to having everything mixed together.
Top comments (3)
Hi, Rens Jasps,
Excellent article !
Thanks for sharing
Very smart and nice directive. Could you tell me how do you detect, if your at the end of the list and trigger the loading action?
Thanks, Marco! Great point! You should definitely check and disable the infinite loader when you're at the end. There are a variety of implementations. Some APIs indicate the total number of items, which helps you determine when you've reached the end. If this info isn't provided, another approach is to check if the response contains 0 items. When at the end, the directive can emit an event like endReached. In your template, you could handle this event to disable further loading, for instance with
(endReached)="infiniteScroll.disabled=true"
.Here's how you could update the directive:
And in your template: