Making Infinite Scroll in Angular Easier and Cleaner

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:


  
    #infiniteScrollState="appInfiniteScrollState"
    appInfiniteScrollState
    [loadFn]="getPokemon"
    [(items)]="items"
    (loadingComplete)="infiniteScroll.complete()"
  >
    @for (item of items; track item.url) {
      
        {{ item.name }}
      
    }
  

   (ionInfinite)="infiniteScrollState.loadNextPage()" #infiniteScroll>
    
  

@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>();
  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();
}

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.

Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post
healthpulse-ai-leverages-mediapipe-to-increase-health-equity

HealthPulse AI Leverages MediaPipe to Increase Health Equity

Next Post
automated-testing-systems-–-pushing-the-envelope

Automated Testing Systems – Pushing the Envelope

Related Posts