A Brief History of Infinite Scrolling
Before the widespread adoption of the Intersection Observer API, infinite scrolling was typically implemented using scroll event listeners. This approach involved attaching an event listener to the scroll event of the window or a scrollable element and then manually checking the scroll position to determine when to fetch more data.
While this method was effective, it had some drawbacks. Scroll event listeners could be performance-intensive, especially on mobile devices or pages with heavy content. Additionally, managing scroll events manually could lead to complex code and potential bugs, especially when dealing with different screen sizes or scroll behaviors.
Intersection observe API
The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.
By introducing of the Intersection Observer API provided a more efficient and streamlined approach to implementing infinite scrolling. By leveraging this API, developers could easily detect when an element comes into view or intersects with another element, making it simpler to trigger data fetching as the user scrolls down the page. This approach improved performance and reduced the complexity of infinite scrolling implementations, leading to a better user experience overall.
Implementation
Here, we are using the Angular framework and dummyjson.com
for our data. One thing that we should consider for implementing
infinite scrolling in our application is that the API should support pagination with limit and skip query parameters.
In this way, we can control how many pages or data we want to load with each fetch. However, we are not going to explain
data fetching or other services here. Let's dive into the code.
Starting from the template/HTML file, here we have a container that holds items and can be scrolled:
<div class="container" #container>
@for(post of posts();track post.id){
<div class="post-item" #item>
<div class="post-title">Title: {{ post.title }}</div>
<span> {{ post.body }} </span>
</div>
}
</div>
It is simple, but let's break down the template. First, we add the #container
to the parent div element to get its reference,
then we loop over the posts()
(it is an Angular signal) that holds the fetched posts and show each post title and body.
Finally, we define the #item
to get the reference of each item.
Now, let's look at the code that gets data to be shown in our HTML part:
export class InfiniteScrollingComponent implements OnInit {
private _api = inject(ApiService);
posts = signal<Post[]>([]);
page = 0;
private readonly LIMIT = 8;
readonly MAX_PAGE_TO_LOAD = 3;
ngOnInit(): void {
this.isLoading.set(true);
this._loadData(); // initial loading
}
private _loadData() {
this._api
.getData(this.LIMIT, this.page * this.LIMIT) // getData(limit, skip)
.pipe(
finalize(() => {
this.isLoading.set(false);
})
)
.subscribe((data) => {
this.posts.update((prev) => [...prev, ...data]);
this.page = this.page + 1;
});
}
}
First, we define some variables to hold data and the number of pages we want to get.
Then, in the ngOnInit
hook, which will be run when the component is initialized, we fetch the data. Here we have the isLoading
for showing initial loading when the page is created, and finally, updating the posts and increasing the page
by one.
Until now, we have only completed a simple task. Let's move forward to implementing the infinite scrolling. The first step is to get our container and items' references. We can do this like:
containerEl = viewChild<ElementRef<HTMLDivElement>>('container');
items = viewChildren<ElementRef<HTMLDivElement>>('item');
Now that we have got the reference of our container and items, we move forward by adding two methods for initializing the intersection
observer and keeping track of the items' visibility. Here is the first method which is _initializeObserver
:
containerViewObserver!: IntersectionObserver;
private _initializeObserver() {
const container = this.containerEl()?.nativeElement;
const options = {
root: container,
rootMargin: '0px',
threshold: 0.5, // Trigger when 50% of the item is visible
};
this.containerViewObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this._loadData();
}
});
}, options);
}
Simply creating a new IntersectionObserver
with our defined options and adding a callback to fire when each of the items
that will be added to this observer (showing in the next step) goes into view. Now let's explain each item in the options:
- root: This option specifies the element that is used as the viewport for checking visibility of the target element. Here, container is set as the root element.
- rootMargin: This option allows you to specify a margin around the root element's bounding box. It can be used to expand or
shrink the area in which intersections are detected. Here, we set it to
0px
, meaning there's no margin. - threshold: This option determines at what percentage of the target's visibility the observer's callback should be executed. It's a value between 0 and 1. Here, it's set to 0.5, which means the callback will be triggered when 50% of the target element is visible in the viewport.
The next method is _observeItemsVisibility
; to add our target item to the intersection observe created in the previous method.
observedRef?: ElementRef<HTMLDivElement>;
private _observeItemsVisibility() {
if (this.observedRef) {
this.containerViewObserver.unobserve(this.observedRef.nativeElement);
}
if (this.page >= this.MAX_PAGE_TO_LOAD) {
this.containerViewObserver.disconnect();
return;
}
const secondLastItem = this.items().at(-2);
if (!secondLastItem) return;
this.observedRef = secondLastItem;
this.containerViewObserver.observe(secondLastItem?.nativeElement);
}
Let's explain the code from line 11: we get the second-to-last item element in the posts list, hold it in the observedRef
, and
finally add it to the observer.
Let's imagine we initially load data and have 8 post items. Here we get the 7th item and check its visibility in the container.
When 50% of this element comes into view, we load the next chunk of data, so we have this chance to load data before we get to the
end of the list (if we scroll slowly😊). After loading data, we have 16 post items in the list, and based on our approach, we should
keep track of element 15 and remove element 7 from observing. This is what we are doing in the first if
condition. The next time
this function is called, we check the observedRef
variable. If it is not null, then we remove/unobserve it from the intersection
observer; otherwise, every time this element comes into view, the callback will be fired. The next if
is for checking the number
of pages we load. If we reach the MAX_PAGE_TO_LOAD
, then we stop/disconnect the intersection observer since we don't need to track
items anymore.
The next thing that we should consider is where these methods need to be called. We call the first one in the ngAfterViewInit
hook:
ngAfterViewInit(): void {
this._initializeObserver();
}
And the next one when we load data each time. For this, we should check the posts()
variable when it becomes updated.
To do this, we can get help from the toObservable
function to keep track of posts()
.
constructor() {
toObservable(this.posts)
.pipe(
delay(500),
filter((data) => !!data.length)
)
.subscribe(() => {
this._observeItemsVisibility();
});
}
Now that we have all the methods and functionality, we can update our template to show loading and other states:
<div class="container" #container>
@if(isLoading()){
<div class="text-center">Loading...</div>
}@else{
@for(post of posts();track post.id){
<div class="post-item" #item>
<div class="post-title">Title: {{ post.title }}</div>
<span> {{ post.body }} </span>
</div>
}
@empty {
<div class="text-center">Nothing to show.</div>
}
} @if(page < MAX_PAGE_TO_LOAD && !isLoading()){
<div class="text-center">Loading more data...</div>
}
</div>
Check this repository for the full code.