Angular: Maximizing Performance with the Intersection Observer API

Learn how to implement the Intersection Observer API using Rx and Angular, and the use-cases where it can help your code be more performant and efficient.

Introduction

The Intersection Observer API is a relatively new functionality in web browsers that can dispatch events when an element’s visibility status changes.

This API allows for a variety of different use-cases that can help with creating more efficient and more performant applications.

In this article, I want to explain how to use it and one of the use-cases that helped me improve efficiency and performance in one of my projects using Angular.

Use-Cases

From a performance-related perspective, here are a few of the many things we can achieve:

lazy-loading images

lazy-rendering children components or heavy content

lazy-loading scripts/stylesheets associated with a particular element

In particular, the Intersection Observer can be used to improve the performance of rendering and processing extremely large lists with complex content or content that requires HTTP requests to be rendered (ex. images).

Tip: Performance optimization not only a software thing. To maximize code reuse and speed up development, use tools like Bit. It’s a great tool and a platform for sharing Angular components from any codebase to a centralized component hub (a private registry and a documentation site)

Example: browsing through shared components in bit.dev

Using the Intersection Observer API

Using the example taken from MDN, here’s a quick example of the simplest way for using the Intersection Observer:

let options = {

root: document.querySelector('#element'),

rootMargin: '0px',

threshold: 0

}; let callback = console.log;

let observer = new IntersectionObserver(callback, options);

Root

The root property takes the element used as a viewport for checking the element’s visibility. If not provided, it defaults to the page viewport

Root Margin

The rootMargin takes a value that will be used to calculate how much margin from the root an element needs to have to be considered “visible”.

For example, if you’re lazy-loading an image, you may want to start downloading the image before it’s fully visible to minimize the amount of time to download it

Threshold

The threshold property takes a number or an array of numbers to define at what percentage the callback should be called. By default, this is 0 (eg, as soon as one pixel is visible).

For example, if we want to run the callback when half of the element becomes visible, we’d pass as the value 0.5 . If we wanted to run callbacks when it’s half-visible and fully visible, we would pass the array [0.5, 1] .

Callback

The following example is also taken from MDN. This is the interface of the object passed as a value from the callback function:

let callback = (entries, observer) => {

entries.forEach(entry => {

// Each entry describes an intersection change for one observed

// target element:

// entry.boundingClientRect

// entry.intersectionRatio

// entry.intersectionRect

// entry.isIntersecting

// entry.rootBounds

// entry.target

// entry.time

});

};

For most simple scenarios, we’d only need to use the isIntersecting property.

Integrating the Intersection Observer API with Angular

Creating a new Observable fromIntersectionObserver

Anything that can be reused, will be reused! It’s a good practice, in my opinion, to leverage RxJS and transform the Browser API into a reactive one by using Observables.

We’re going to create an Observable fromIntersectionObserver .

This observable will emit notifications when the visibility of an element changes and will give as 3 statuses: visible , invisible , or pending .

The latter status is based on a delay defined by the consumer: if the element is not visible within the delay set, it will be set as pending .

The Observable takes 3 arguments:

element: the element being observed

config: this is the configuration passed to the IntersectionObserver class

debounce: this is the time (in milliseconds) to debounce

export const fromIntersectionObserver = (

element: HTMLElement,

config: IntersectionObserverInit,

debounce = 0

) => ...

We create an intermediate subject to debounce the notifications:

const subject$ = new Subject<{

entry: IntersectionObserverEntry;

observer: IntersectionObserver;

}>();

Then, we define a function to check if an element is intersecting:

function isIntersecting(entry: IntersectionObserverEntry) {

return entry.isIntersecting || entry.intersectionRatio > 0;

}

Next, let’s create the intersection observer and we start observing the element passed as argument:

const intersectionObserver = new IntersectionObserver(

(entries, observer) => {

entries.forEach(entry => {

if (isIntersecting(entry)) {

subject$.next({ entry, observer });

}

});

}, config

); // start observing element visibility

intersectionObserver.observe(element);

Now we need to check if the element is visible once debounced. In order to do this, we need to:

debounce the notifications coming from the Intersection Observer

check again if the element is visible with a one-off function that checks if the element is within the viewport, and passes the notification down to the Observable subscriber with its current status

Let’s define a function named isVisible and returns a Promise with the current visibility status of the element:

async function isVisible(element: HTMLElement) {

return new Promise(resolve => {

const observer = new IntersectionObserver(([entry]) => {

resolve(entry.isIntersecting);

observer.disconnect();

}); observer.observe(element);

});

}

And now we pass the notifications down to the Observable’s Subscriber:

// Emit pending notifications

subject$.subscribe(() => {

subscriber.next(IntersectionStatus.Pending);

}); // Emit visible notifications (if visible)

subject$

.pipe(

debounceTime(debounce),

filter(Boolean)

)

.subscribe(async ({ entry, observer }) => {

const isEntryVisible = await isVisible(entry.target); if (isEntryVisible) {

subscriber.next(IntersectionStatus.Visible);

observer.unobserve(entry.target);

} else {

subscriber.next(IntersectionStatus.NotVisible);

}

});

Finally, we provide the teardown function when the consumer unsubscribes the observable.

We need to both complete the subject and disconnect the observer:

return {

unsubscribe() {

intersectionObserver.disconnect();

subject$.complete();

}

};

Creating the IntersectionObserverDirective Directive

A nice way I’ve found that could work well for Angular is to create a directive and assign it to the element we want to target.

TLDR: The directive subscribes to the Observable fromIntersectionObserver and emits an output with the current status of the element on which the directive is applied:

@Directive({

selector: '[intersectionObserver]'

}) export class IntersectionObserverDirective implements OnInit, OnDestroy {

@Input() intersectionDebounce = 0;

@Input() intersectionRootMargin = '0px';

@Input() intersectionRoot: HTMLElement;

@Input() intersectionThreshold: number | number[];



@Output() visibilityChange = new EventEmitter<IntersectionStatus>();



private destroy$ = new Subject();



constructor(private element: ElementRef) {} ngOnInit() {

const element = this.element.nativeElement;



const config = {

root: this.intersectionRoot,

rootMargin: this.intersectionRootMargin,

threshold: this.intersectionThreshold

}; fromIntersectionObserver(

element,

config,

this.intersectionDebounce

).pipe(

takeUntil(this.destroy$)

).subscribe((status) => {

this.visibilityChange.emit(status);

});

}



ngOnDestroy() {

this.destroy$.next();

}

}

Notice: the directive and the Observable are simple implementations and not ready for production use. Please don’t simply dump this code in your app, but take it as a reference and build on top of it ☺

Appling the IntersectionObserverDirective Directive

Now that we have created a directive, we can use it in our application’s code.

Scenario: we have a list with thousands of elements, and we want to lazy-load their content based on whether they’re visible or not.

For demonstration purposes, we simply create a list with 1000 items:

class AppComponent {

list = [];

visibilityStatus: {[key: number]: IntersectionStatus} = {};

intersectionStatus = IntersectionStatus;



ngOnInit() {

for (let n = 0; n < 1000; n++) {

this.list.push(n);

}

} onVisibilityChanged(index: number, status: IntersectionStatus) {

this.visibilityStatus[index] = status;

}

}

We create the list

We keep a simple and naive map called visibilityStatus with the status of each item based on the index

Here’s how the directive is applied (full source-code will be provided at the end of the article):

<div

intersectionObserver

[intersectionDebounce]="1000"

(visibilityChange)="onVisibilityChanged(item, $event)"

> <div

*ngIf="visibilityStatus[item] === intersectionStatus.Visible"

>

<!-- SHOW COMPLEX CONTENT -->

</div>

</div>

Here is a demonstration of what we have achieved:

If the element is visible, it will have a green check emoji next to it

If the element is not visible yet it will have a red cross

If the element is pending, it will have a loading indicator

Why is the debounce attribute useful?

If the user is scrolling a list very fast, it’s likely that the user never needs to be loaded at all, as the user’s already gone past it. Debouncing helps with avoiding useless computations following the user’s behavior.

Demo and Source Code

The final demo can be seen at the following link: https://intersection-observer-api.stackblitz.io.

The source code can be found at the following link: https://stackblitz.com/edit/intersection-observer-api.

If you need any clarifications, or if you think something is unclear or wrong, do please leave a comment!