DEV Community

Rens Jaspers
Rens Jaspers

Posted on

Making Infinite Scroll in Angular Easier and Cleaner

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>
Enter fullscreen mode Exit fullscreen mode
@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);
}
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode
  1. 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.

  2. page: This property keeps track of which page you are currently on. It starts from 0.

  3. itemsChange: This output lets the parent component know when new items are added.

  4. loadingError: If there's an error while loading data, this output sends an error message to the parent component.

  5. loadingChange: It informs about the loading state – whether data is being loaded or not.

  6. 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();
}
Enter fullscreen mode Exit fullscreen mode

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(),
    });
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
jangelodev profile image
João Angelo

Hi, Rens Jasps,
Excellent article !
Thanks for sharing

Collapse
 
marco_43 profile image
Marco

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?

Collapse
 
rensjaspers profile image
Rens Jaspers

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:

  // ... other code
  @Output() endReached = new EventEmitter<void>();

  private handleNewItems(items: T[]): void {
    if (this.isEndReached(items)) {
      this.endReached.emit();
      return;
    }
    this.items = [...this.items, ...items];
    this.itemsChange.emit(this.items);
    this.page++;
    this.pageChange.emit(this.page);
  }

  private isEndReached(items: T[]) {
    return items.length === 0; // or some custom logic
  }
Enter fullscreen mode Exit fullscreen mode

And in your template:

<ion-content>
  <ion-list
    #infiniteScrollState="appInfiniteScrollState"
    appInfiniteScrollState
    [loadFn]="getItems"
    [(items)]="items"
    (endReached)="infiniteScroll.disabled=true"
    (loadingComplete)="infiniteScroll.complete()"
  >
    <ng-container *ngFor="let item of items">
      <ion-item>
        <ion-label>{{ item.name }}</ion-label>
      </ion-item>
    </ng-container>
  </ion-list>

  <ion-infinite-scroll (ionInfinite)="infiniteScrollState.loadNextPage()" #infiniteScroll>
    <ion-infinite-scroll-content></ion-infinite-scroll-content>
  </ion-infinite-scroll>
</ion-content>
Enter fullscreen mode Exit fullscreen mode