Lazy infinite lists based on Deferrable Views

Lazy infinite lists based on Deferrable Views

Hello everybody! My name is Pavlo Sapachov, I work in architecture and front-end development in the Tinkoff Leasing project. We love to create user-friendly, responsive and productive interfaces. One of the improvements is viewing item collections. The most popular approaches to viewing collections are pagination and scrolling, called infinite lists.

If the pagination is a classic and clear representation of navigation, creating endless lists has a number of pitfalls and technical difficulties.

In the article I will share the implementation of lists based on Deferrable Viewsthat recently appeared in Angular 17.

End-of-list detection methods

The main challenge in creating infinite lists is determining when the user needs to load and display the next part of the collection of elements. A load trigger is the moment when the user has reached the end of the page or the last element in the container.

The methods of determining the end of the list changed later. In the early days of the infinite list approach, around the time of Internet Explorer 10, the primary way was to check the value of a property scrollTop document or list container. The value was then compared to the height of the document or container.

Around then, there were various variations of determining the last element by its coordinates in the visibility zone through the appeal to the method getBoundingClientRect().

The year 2017 brought significant improvements, when browsers began implementing the Intersection Observer API, which is based on working with calls to the mentioned method getBoundingClientRect()only with the provision of a more convenient interface.

The Intersection Observer API implements the “Observer” pattern, the essence of which is to create a subscription to changes in the position of the observed element. Using the data obtained when the subscription is triggered, you can implement various logic, including the initiation of the call to download the next part of the collection and its subsequent display to the user.

The listed end-of-list detection methods are native and implemented by browsers, which imposes certain restrictions on the versions used, especially in cases where support for older browsers is required. The remark is most true about the Intersection Observer API, because now there are practically no situations left when you need to use such an old version of the browser that does not even support scrollTop.

On the Internet, you can find many examples of the implementation of the Intersection Observer API in various frameworks and libraries. For example, Angular 16 and earlier versions use custom directivesthat wrap API calls.

The recent release of Angular 17 introduced Deferrable Views, which make it possible to add some magic and convenience to your components.

The developers of Angular, when creating Deferrable Views, laid their basis on the use of existing browser APIs, such as requestIdleCallback and Intersection Observer APIs, as well as wrappers around event handlers click, keydown, mouseenter, focusin. In other words, syntactic sugar has appeared, which greatly simplifies work with the listed functions.

In the documentation Angular has examples of using Deferrable Views with various triggers, but for the purposes of this article we are only interested in the trigger on viewportwhich fires when an element comes into view by using the Intersection Observer API under the hood.

Implementation of the download trigger

The implementation of the loading trigger is the cornerstone of organizing infinite lists. By applying a trigger on viewportwe can use the template to tell the component when to call the data service to load the next part.

The task can be solved in different ways: for example, using an image that will listen to the event load. It initiates the launch of a call to the data service:

@defer (on viewport) {
  <img (load)=”loadMore()” src=”...” />
}

The disadvantage of using an image is that it should be available image in static resources or on a CDN, as well as network overhead.

Another way is to load an empty component, the main task of which is to notify the parent component that it has been created by appearing in the visibility area:

@defer (on viewport) {
  <app-load-trigger (init)="loadMore()" />
}

The code of the trigger component is primitive:

export class LoadTriggerComponent implements OnInit {
  @Output()
  init = new EventEmitter();

  ngOnInit(): void {
    this.init.emit();
  }
}

The obvious advantage of this method is the absence of any requests through the network to solve the problem by means of Angular itself.

Rearranging the trigger to the end of the list

The main difficulty is essentially the root cause: how do we make the trigger component be at the end of the list and fire every time we reach the end of the list?

The answer would be to use a structure directive NgForwhich has a number of excellent local variables, including a boolean variable last.

Thanks to the use last it is possible to describe a pattern in which the view will always be rendered at the end of the list and, importantly, redrawn every time the list is updated, hiding the new view from view.

In simplified form, without using the new Control Flow syntax, the template code would look like this:

<app-list-item *ngFor="let item of list; last as isLast">
  <ng-container *ngIf="isLast">
    @defer (on viewport) {
      <app-load-trigger (init)="loadMore()" />
    } @placeholder {
      <div></div>
    }
  </ng-container>
</app-list-item>

Availability placeholder – Mandatory part of the syntax @defer (on viewport) and contains an empty block, which is useless for our purpose. The peculiarity of the implementation is that the new trigger component will not be re-created if the list has not been updated. For example, the data service did not send any other collection addition data.

Stop endless download

An additional challenge is determining when no more data needs to be requested. In other words, when was the end of the list reached? The previous paragraph will be a partial answer: if the array of elements has not been updated, nothing needs to be redrawn and the trigger component does not need to be rearranged either.

For the purpose of optimization, it is good to be able to predict in advance the need to install the trigger component. If you do not rely on the responses from the data service, which may report that it has given the last part of the collection of elements, you can independently check in the same NgFor whether the element is both the last in the list and the last at all.

This can be done using a local variable index and write a simple component method to help define:

isLastInBunch(index): boolean {
  return (index + 1) % PAGE_SIZE === 0;
}

The essence of the method is simple: try to understand whether the element being checked is the last in the list, based on whether its index of the portion of the collection elements is stolen (PAGE_SIZE). This approach is not ideal: it may turn out that our list contains the total number of elements, but with some probability it solves the problem of an unnecessary last query.

Lazy infinite list demo

I’ve prepared an example to show live how to organize a lazy infinite list:

In the example, delays are intentionally added to the collection app in order to see when the download is triggered and the download message is displayed.

For demonstration purposes, the list is endless and does not include examples of download termination. Using the code examples provided in the article, it is easy to add this logic to your application.

About the approach

The organization of a lazy infinite list through Deferrable Views cannot be called ideal in all respects, primarily due to incompatibility with older versions of Angular. But, if you can use Angular 17, it will be a good alternative to the previously existing approaches, as it makes it possible to clean up the code, getting rid of complex bindings for working with the Intersection Observer API or more primitive methods.

Separately, it is worth mentioning the compatibility of the approach with the use of virtual scrolling, which opens up additional opportunities for optimizing work with large lists. In this article, I decided not to consider the application of virtualization precisely in order to focus on the proposed approach, and not on the description of use cases.

In the comments, we can discuss the specifics of the approach and the questions that have arisen.

Related posts