Skip to main content
Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.

Using IntersectionObserver And NgSwitch To Defer Template Bindings In Angular 11.0.5

By Ben Nadel on

Showing a really long list of data is usually not that great for the user experience (UX); and, it's usually not that great for the performance of the web page. But, sometimes you don't have a choice. And, in those cases, I often try to find ways to squeeze as much performance as possible out of the rendering. One approach that I've wanted to experiment with lately is the use of the IntersectionObserver API as a means to defer template bindings within an Angular view. The hope being that, with fewer bindings, we'll hit fewer performance bottlenecks.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

The premise of this experiment is that, within a given "row of data", some of the bindings are "critical" and need to be visible at all times in order to drive things like the CMD+F (Find) feature of the browser. And, other bindings are secondary and can perhaps be deferred until that row is in view. For example, you may not need to populate a <select> menu until the parent row is in front of the user's eyeballs.

At first, you might want to create an ngIf inspired directive that only shows an element if it is within the viewport:

<div *ngIfVisible> deferred rendering </div>

This approach is fundamentally flawed as you quickly run into the chicken-and-egg problem: you can't bind the IntersectionObserver to the element you are hiding since it will never become visible, and will therefore never intersect with the viewport.

Really what you want to do is observe a parent / ancestor element; and then use that element's intersection to drive the visibility of a child / descendant element. In Angular, we already have a set of directives that operate with a parent/child relationship: ngSwitch and ngSwitchCase. To keep this exploration simple, I'm going to piggy-back on these directives, piping (so to speak) an intersection property into an ngSwitch binding.

To try this out, I'm going to create an attribute directive, [bnIntersectionObserver] that exposes an intersection.isIntersecting reference. This reference will then be bound to an ngSwitch input such that we can use ngSwitchCase directives to defer template bindings:

<div
	bnIntersectionObserver
	#intersection="intersection"
	[ngSwitch]="intersection.isIntersecting">

	<div>
		I am always rendered.
	</div>

	<div *ngSwitchCase="true">
		I am ONLY RENDERED when "intersection.isIntersecting" is TRUE.
	</div>
</div>

An [bnIntersectionObserver] attribute directive like this doesn't have a lot of logic in it. It just observes the host element using the IntersectionObserver API and then updates an isIntersecting property. And, since we're changing the view-model based on the intersection changes, we aren't even going to bother running this outside of the Angular Zone.

// Import the core angular services.
import { Directive } from "@angular/core";
import { ElementRef } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@Directive({
	selector: "[bnIntersectionObserver]",
	exportAs: "intersection"
})
export class IntersectionObserverDirective {

	public isIntersecting: boolean;
	// These are just some human-friendly constants to make the HTML template a bit more
	// readable when being consumed as part of SWTCH/CASE statements.
	public IS_INTERSECTING: boolean = true;
	public IS_NOT_INTERSECTING: boolean = false;

	private elementRef: ElementRef;
	private observer: IntersectionObserver | null;

	// I initialize the intersection observer directive.
	constructor( elementRef: ElementRef ) {

		this.elementRef = elementRef;
		this.observer = null;

		// By default, we're going to assume that the host element is NOT intersecting.
		// Then, we'll use the IntersectionObserver to asynchronously check for changes
		// in viewport visibility.
		this.isIntersecting = false;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I get called once when the host element is being destroyed.
	public ngOnDestroy() : void {

		this.observer?.disconnect();
		this.observer = null;

	}


	// I get called once after the inputs have been bound for the first time.
	public ngOnInit() : void {

		this.observer = new IntersectionObserver(
			( entries: IntersectionObserverEntry[] ) => {

				// CAUTION: Since we know that we have a 1:1 Observer to Target, we can
				// safely assume that the entries array only has one item.
				this.isIntersecting = entries[ 0 ].isIntersecting;

			},
			{
				// This classifies the "intersection" as being a bit outside the
				// viewport. The intent here is give the elements a little time to react
				// to the change before the element is actually visible to the user.
				rootMargin: "300px 0px 300px 0px"
			}
		);
		this.observer.observe( this.elementRef.nativeElement );

	}

}

As you can see, there's very little going on here; we have a one-to-one relationship between directives and IntersectionObserver instances (which may or may not be bad performance); and, we just toggle a Boolean value. This directive has no idea how this value is going to be consumed.

In our demo App component, we're then going to create a bunch of User objects:

// Import the core angular services.
import { Component } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

interface User {
	id: number;
	name: string;
	email: string;
	avatarUrl: string;
	lastLoginAt: string;
	city: string;
	state: string;
	country: string;
}

@Component({
	selector: "app-root",
	styleUrls: [ "./app.component.less" ],
	templateUrl: "./app.component.html"
})
export class AppComponent {
	
	public users: User[];

	// I initialize the app component.
	constructor() {

		this.users = [];

		while ( this.users.length < 100 ) {

			this.addUser();

		}

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I add a demo user to the users collection.
	public addUser() : void {

		this.users.unshift({
			id: ( this.users.length + 1 ),
			name: "Ben Nadel",
			email: "ben@bennadel.com",
			avatarUrl: "https://bennadel-cdn.com/images/global/ben-nadel-avatar.jpg",
			lastLoginAt: "Today",
			city: "Irvington",
			state: "New York",
			country: "US"
		});

	}

}

When it comes to rendering these User objects, we're going to consider the Name and Email data-points to be critical to the browser page behavior (again, to power things like the native Find functionality). But, for data-points like Avatar, Location, and Last Login date, we're going to consider those secondary and try to defer the relevant template bindings using ngSwitchCase:

<p>
	<a (click)="addUser()" class="digest">
		Add User ( trigger digest )
	</a>
</p>

<ng-template ngFor let-user [ngForOf]="users">

	<!--
		The [bnIntersectionObserver] directive attaches this host element to an instance
		of the IntersectionObserver and exposes itself as the "intersection" reference.
		This reference further exposes an ".isIntersecting" property which toggles
		between (true) and (false) as the host element intersects with the viewport.
		We're then going to pipe that value into the native [ngSwitch] directive in order
		to defer some of the template bindings.
	 -->
	<div
		bnIntersectionObserver
		#intersection="intersection"
		[ngSwitch]="intersection.isIntersecting"
		class="user">

		<div class="user__header">
			<div class="user__avatar">
				<!-- NOTE: IMG is not loaded until intersecting. -->
				<img
					*ngSwitchCase="intersection.IS_INTERSECTING"
					[src]="user.avatarUrl"
					class="user__avatar-image"
				/>
			</div>
			<div class="user__info">
				<span class="user__name">
					{{ user.name }}
				</span>
				<span class="user__email">
					{{ user.email }}
				</span>
			</div>
		</div>

		<!-- NOTE: DIV is not loaded until intersecting. -->
		<div *ngSwitchCase="intersection.IS_INTERSECTING" class="user__meta">
			<span class="user__meta-item">
				<strong class="user__meta-label">
					Location:
				</strong>
				<span class="user__meta-value">
					{{ user.city }} {{ user.state }}, {{ user.country }}
				</span>
			</span>
			<span class="user__meta-item">
				<strong class="user__meta-label">
					Last Login:
				</strong>
				<span class="user__meta-value">
					{{ user.lastLoginAt }}
				</span>
			</span>
			<span class="user__meta-item">
				<strong class="user__meta-label">
					ID:
				</strong>
				<span class="user__meta-value">
					{{ user.id }}
				</span>
			</span>
		</div>

	</div>

</ng-template>

As you can see, some of the data here is always being rendered; and, some of the data is being hidden behind *ngSwitchCase bindings which will only be rendered once the <div> element is intersecting with the browser's viewport. And, when we run this in the browser and look at the last item in the list, we can see that the deferred bindings don't show up until we scroll down to the bottom of the document:

ngSwitch being used to defer template bindings based on the IntersectionObserver state in Angular 11.0.5.

It works pretty nicely; and, by using ngSwitch to power the template bindings, we only need to add the little bit of code to manage the IntersectionObserver. But, when putting this together in Angular 11.0.5, a few questions that come to mind:

Should I Use One IntersectionObserver Instance? Or One Per Element?

In this demo, I'm using one IntersectionObserver instance per Element. Which means, every instance only observes a single element. Does this have a performance impact? Would it have been more efficient to have a single IntersectionObserver, and then use it to observe all the elements in question?

I am not sure. According to this discussion on the W3C GitHub, I get the sense that it shouldn't make much of a performance difference; though, it may have a memory impact. From what people are saying in that Issue, it seems like the cost of the callbacks is really the most limiting factor.

But, it's probably something worth testing.

Is the IntersectionObserver "Less Expensive" Than the Template Bindings?

The goal of the IntersectionObserver here is to reduce the load on the page when a change-detection digest is triggered. And, since a digest runs at least once per view-rendering, the ultimate goal here is to reduce the time it takes for the initial rendering of the list (and, secondarily, to reduce the time it takes to run all subsequent change-detection digests).

That said, I don't know how to effectively test whether the reduced string-interpolation bindings outweigh the cost of the additional directive bindings and the IntersectionObserver instantiation. It is likely dependent on how many bindings are being deferred in your particular view.

I suspect that this will come down to a "try it and see what it feels like" kind of answer. Until we run into a page that actually has a lot of items rendering, we probably won't be able to effectively tell how this kind of approach changes the user experience (UX).



Reader Comments

@All,

I've been thinking more about the performance here; and, one thing to consider with the one instance per Element approach (that we have in this demo) is that each callback will trigger Change Detection. This is likely the biggest factor in whether or not this approach is too expensive. I am working on a follow-up demo which tries to show this more clearly.

Reply to this Comment

@All,

As I mentioned above, I wanted to follow-up with a quick look at one shared instance of the IntersectionObserver API vs. many instances (one per element):

www.bennadel.com/blog/3954-intersectionobserver-api-performance-many-vs-shared-in-angular-11-0-5.htm

The shared one is clearly faster (especially in Firefox). But, the underlying reason may be the number of change-detection digests that get triggered. The root performance bottleneck is still not absolutely clear to me and I'll keep digging in a bit.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Blog
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.