DEV Community

Paweł Łaski
Paweł Łaski

Posted on

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

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.

  1. Renders N elements and adds an IntersectionObserver on the last element.
  2. 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();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)