Angular – Lazy ngFor when you only need N elements to start

RMAG news

When we started writing classifieds website – mamrzeczy.pl, we wanted the main page with ads to display 40 ads per page. But we quickly noticed that lighthouse gave a very low performance score. We decided that we needed to somehow limit the amount of content loaded at the start of the page. And this is how the NgForLazyDirective was created.

The directive works in a very simple way.

Renders N elements and adds an IntersectionObserver on the last element.
When the user scrolls to the last element, it renders N elements again as in step 1.

I’m posting it because maybe someone will find such simple use useful, as the directive itself is not too invasive and does not require many changes to the design.

import { NgFor, NgForOfContext } from @angular/common;
import {
ChangeDetectorRef,
Directive,
Input,
IterableDiffers,
NgIterable,
OnChanges,
SimpleChanges,
TemplateRef,
ViewContainerRef,
} from @angular/core;
import { isPlatformBrowser } from ./inject-functions/is-platform-browser;

@Directive({
// eslint-disable-next-line @angular-eslint/directive-selector
selector: [ngFor][ngForLazy],
standalone: true,
})
export class NgForLazyDirective<T, U extends NgIterable<T> = NgIterable<T>>
extends NgFor<T, U>
implements OnChanges
{
@Input() ngForLazy!: U & NgIterable<T>;
@Input() ngForN: number | undefined | null;
observer!: IntersectionObserver;
end = 0;

constructor(
private viewContainer: ViewContainerRef,
template: TemplateRef<NgForOfContext<T, U>>,
differs: IterableDiffers,
private cdr: ChangeDetectorRef,
) {
super(viewContainer, template, differs);

if (isPlatformBrowser()) {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.observer.unobserve(entry.target);
this.render();
}
});
if (this.ngForN && this.end < Array.from(this.ngForLazy).length) {
this.observer.observe(
viewContainer.element.nativeElement.parentElement.lastChild
.previousElementSibling as Element,
);
}
},
{
rootMargin: 100px,
},
);
}
}

isObserved = false;

observe() {
if (this.isObserved) {
return;
}
const element = this.viewContainer.element.nativeElement.parentElement
.lastChild.previousElementSibling as Element;
if (this.observer && element) {
this.observer.observe(element);
this.isObserved = true;
}
}

disconnect() {
if (this.observer) {
this.observer.disconnect();
this.isObserved = false;
}
}

getForOf() {
return Array.from(this.ngForLazy).slice(0, this.end) as U & NgIterable<T>;
}

render() {
if (!this.ngForN) {
this.ngForOf = this.ngForLazy;
this.disconnect();
super.ngDoCheck();
this.cdr.markForCheck();
return;
}
this.end += this.ngForN;
this.ngForOf = this.getForOf();
this.observe();
super.ngDoCheck();
this.cdr.markForCheck();
}

ngOnChanges(changes: SimpleChanges): void {
if (ngForLazy in changes) {
this.render();
}
if (ngForN in changes) {
this.render();
}
}
}

Example of use in code:

<div *ngFor=“let item; lazy: items; trackBy: trackBySlug; n: 10”>
<a routerLink=“/ad/{{ item.slug }}”>
<app-ads-item [classifiedAd]=“item” />
</a>
</div>

If you like this post, give it a heart. 💕
If you have a question, leave a comment, I will be happy to discuss. 💬

Leave a Reply

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