DEV Community

Cover image for Template-Level Lazy Loading in Angular
Ilir Beqiri for This is Angular

Posted on

Template-Level Lazy Loading in Angular

Working with a component-based framework such as Angular, you may witness that templates are a crucial building block of components. Thanks to the template's flexibility and the declarative APIs it supports, we can create highly dynamic web applications. In Angular 17, a ton of great features were introduced, and one of great importance is the new block template syntax, known as @-syntax notation, which led to the introduction of a few new APIs in the template.

These APIs further extend the template's HTML syntax, with the one standing out amongst them, known as Deferrable Views, accessible through the @defer block.

Currently in developer preview, Deferrable Views, augment the Angular templates with a declarative, built-in mechanism, that allows us, developers, to specify which template portions - components, directives, pipes, and any associated CSS - to be lazily loaded later when needed.

In this article, I will not do an in-depth guide to Deferrable Views but I want to show the manual work that the Angular team took away from us, developers, and moved to the framework itself thus allowing us to achieve lazy loading benefits through a declarative template syntax.

For the showcase, I am going to demo the most often use-case I used to do lazy loading: below-the-fold content, that will be loaded and rendered when in viewport.

Showcase requirements

To better understand what we want to showcase here, let's first define the points we want to implement, as the following:

  • Trigger lazy loading when in the viewport.
  • Handle the loading and error state accordingly.
  • Handle the flickering issue.

Those represent the main, basic points for our showcase, and later afterward we can implement more complex points like:

  • Early triggering of lazy loading using another trigger element.
  • Lazy loading of multiple template portions (read: components).

Let's dive in 🚀

Classic Template-Level Lazy Loading

Before Angular 17, there were existing imperative APIs that made it possible to create template portions dynamically - components, directives, pipes, and any associated CSS - and were closely similar to the way how Angular internally handles component creation, and manages templates and views. The dependencies planned to be lazily loaded did not need to be present in the template, and we heavily relied on the imperative usage of JavaScript's dynamic imports to asynchronously load their corresponding modules at runtime.

An advantage of these classic APIs is that you could take different ways of doing lazy loading, so what I will show here is maybe the simplest way of doing it.

The sample demo 🐱‍🏍

As a starting point, let's use the UserProfile component below:

@Component({
  ...
  template: `
    <div class="wrapper">
      <app-details></app-details>
    </div>

    <div class="wrapper wrapper-xl">
      <app-projects></app-projects>
      <app-achievements></app-achievements>
    </div>

    ...
  `,
  imports: [ProjectsComponent, AchievementsComponent]
  ...
})
export class UserProfileComponent {}
Enter fullscreen mode Exit fullscreen mode

The Details component shows a pretty long description of the user, meanwhile, the Projects and Achievements components render the list of projects and achievements. As a result of the description being very long, those two components are not visible initially to the user, they are out of the viewport 👇:

The initial view of the profile page

This kind of content is known as below-the-fold content, and it's a perfect candidate for lazy loading when working toward improving the initial page load and application bundle.

Initially, we will lazy load only the Projects component, and be sure to fulfill the main, basic points we defined above. By using classic APIs, here's how the template would initially look like:

type DepsLoadingState = 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETE' | 'FAILED';

@Component({
  ...
  template: `
    <div class="wrapper">
      <app-details></app-details>
    </div>

    <div class="wrapper wrapper-xl">
      <ng-template #contentSlot /> // 👈 insert lazily loaded content here 

      <ng-container *ngIf="depsState$ | async as state">
        <ng-template *ngIf="state == 'IN_PROGRESS'" [ngTemplateOutlet]="loadingTpl"></ng-template>
        <ng-template *ngIf="state == 'FAILED'" [ngTemplateOutlet]="errorTpl"></ng-template>
      </ng-container>

      <ng-template #loadingTpl>
        <app-projects-skeleton />
      </ng-template>

      <ng-template #errorTpl>
        <p>Oops, something went wrong!</p>
      </ng-template>
    </div>
    ...
  `,
  imports: [] // 👈 no need to import
  ...
})
export class UserProfileComponent {
  depsState$ = new BehaviorSubject<DepsLoadingState>('NOT_STARTED');
}
Enter fullscreen mode Exit fullscreen mode

As you can see, components are removed from the template, and not imported in the imports array of component's or NgModule's metadata. Then, we defined the container or slot (template ref #contentSlot) in the template (using ng-template) where the lazily loaded content will be inserted.

Besides these, we have also defined the loading, and error templates (again, using ng-template) to help mirror the state of the loading dependencies (tracked by depsState$ subject) into the template.

but 🤚… initially, nothing happens, as no code triggers the loading process …😕

To fulfill the first point - start loading when in the viewport - we need to define a trigger which is nothing more than the action that needs to occur for the loading of template dependencies to start. In our case, we want to show the content when in the viewport (trigger action) but with the initial template, the content is not present. So, we have to define what is going to trigger that action.

The placeholder template for the rescue 💪

To have some initial content there in the view, let's add a temporary template, that when in the viewport, triggers the loading of desired content. This temporary template is known as a placeholder template 👇:

...
template: `
  ...
  <ng-container *ngIf="depsState$ | async as state">
        <ng-template *ngIf="state == 'NOT_STARTED'" 
          [ngTemplateOutlet]="placeholderTpl">
        </ng-template>
        ...
  </ng-container>

  <ng-template #placeholderTpl>
    <p>Projects List will be rendered here...</p> // 👈 trigger element
  </ng-template>
  ...
`
...
Enter fullscreen mode Exit fullscreen mode

The placeholder template, also known as the trigger element is defined as a ng-template because it will be removed after it fires the loading of template dependencies, the Project component in our case.

Now with the trigger element in place, all that is left is to define the trigger itself which fires the loading when the trigger element (placeholder) enters the viewport. 

For this purpose, we are going to use the IntersectionObserver Web API. We encapsulate the logic in a directive, which emits an event whenever the element it is applied on (trigger element) enters the viewport and stops tracking/observing the trigger element afterward, as below 👇:

@Directive({
    selector: '[inViewport]',
    standalone: true
})
export class InViewportDirective implements AfterViewInit, OnDestroy {
    private elRef = inject(ElementRef);

    @Output()
    inViewport: EventEmitter<void> = new EventEmitter();

    private observer!: IntersectionObserver;

    ngAfterViewInit() {
        this.observer = new IntersectionObserver((entries) => {
            const entry = entries[entries.length - 1];
            if (entry.isIntersecting) {
                this.inViewport.emit();
                this.observer.disconnect();
            }
        });

        this.observer.observe(this.elRef.nativeElement)
    }

    ngOnDestroy(): void {
        this.observer.disconnect();
    }
}
Enter fullscreen mode Exit fullscreen mode

Once emitting, it is in the UserProfile component to take care of the loading process next:

@Component({
  ...
  template: `
    ...
    <div class="wrapper wrapper-xl">
      ...
      <ng-template #placeholderTpl>
        // 👇 apply directive to the trigger element
        <p (inViewport)="onViewport()">
            Projects List will be rendered here...
        </p> 
      </ng-template>
      ...
    </div>
    ...
  `,
  imports: [InViewportDirective]
  ...
})
export class UserProfileComponent {
  @ViewChild('contentSlot', { read: ViewContainerRef }) 
  contentSlot!: ViewContainerRef;

  depsState$ = new BehaviorSubject<DepsLoadingState>('NOT_STARTED');

  onViewport() {
    this.depsState$.next('IN_PROGRESS');

    const loadingDep = import("./user/projects/projects.component");
    loadingDep.then(
      c => {
        this.contentSlot.createComponent(c.ProjectsComponent);
        this.depsState$.next('COMPLETE');
      },
      err => this.depsState$.next('FAILED')
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

To asynchronously load the component, we use JavaScript's dynamic import function, and then update the tracking state following the state of the loading process thus properly mirroring the state in the template too. Since the loading logic is in the component class, we have to query the container/slot in the template and then use it to instantiate and insert the host of the loaded component into the container after it has been loaded, as you can see below 👇:

Lazy Loading of projects with flickering issue
And … that's great! We have a working solution. But if you look carefully, one can observe that the Project component is loaded but the placeholder is hardly visible, and the loading template is not rendered at all because projects are rendered. This can happen if the dependencies are loaded quickly, thus implying some sort of flickering when loading is ongoing. 

This leads us to the 3rd main point we defined above, and this can be resolved by coordinating the time when the placeholder and loading template render, as below:

function delay(timing: number) {
  return new Promise<void>(res => {
    setTimeout(() => {
      res()
    }, timing);
  })
}

@Component({...})
export class UserProfileComponent {
  @ViewChild('contentSlot', { read: ViewContainerRef }) 
  contentSlot!: ViewContainerRef;

  depsState$ = new BehaviorSubject<DepsLoadingState>('NOT_STARTED');

  onViewport() {
    // time after the loading template will be rendered
    delay(1000).then(() => this.depsState$.next('IN_PROGRESS'));

    const loadingDep = import("./user/projects/projects.component");
    loadingDep.then(
      c => {
        // minimum time to keep the loading template rendered
        delay(3000).then(() => {
          this.contentSlot.createComponent(c.ProjectsComponent);

          this.depsState$.next('COMPLETE')
        });
      },
      err => this.depsState$.next('FAILED')
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

Thus ensuring users a better visual indication of what is happening and a smoother experience 🤗:

Lazy Loading of projects without flickering issue
We have fulfilled all of the 3 main points we planned to showcase. A complex one but often, a common requirement, is when we want the loading to start a little bit earlier - before the user scrolling makes the placeholder enter the viewport. This means that the trigger element is different from the placeholder template, and another element above in the template has to be used for that purpose, see below:

@Component({
  ...
  template: `
     ...
    // 👇 trigger element somewhere above in the template
    <span (inViewport)="onViewport()"></span>

    <div class="wrapper wrapper-xl">
      <ng-template #contentSlot /> // 

      <ng-container *ngIf="depsState$ | async as state">
        <ng-template *ngIf="state == 'NOT_STARTED'" [ngTemplateOutlet]="placeholderTpl"></ng-template>
        <ng-template *ngIf="state == 'IN_PROGRESS'" [ngTemplateOutlet]="loadingTpl"></ng-template>
        <ng-template *ngIf="state == 'FAILED'" [ngTemplateOutlet]="errorTpl"></ng-template>
      </ng-container>

      <ng-template #placeholderTpl>
        <p>Projects List will be rendered here...</p>
      </ng-template>
      ...
    </div>
    ...
  `,
  imports: [InViewportDirective]
  ...
})
export class UserProfileComponent {
  ...
  onViewport() {
    // same implementation as above
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, while the user scrolls down and the trigger element (span) enters the viewport, the loading of dependencies starts, see below:

Early trigger of lazy loading projects
That is easily verified, as you can see the loading message in the view when scrolling to the Projects section of the page. That's great 😎.

Lazy loading of multiple components

The code we wrote is imperative and simple, but we have one last point to fulfill: to lazy load more than one component. To showcase, the Achievements component is ready to be loaded alongside the Projects component.

There are 2 paths we can follow for this case: either load them separately or together. If we decide to follow the former option, the same work needs to be done as we did for the Products component, and to be fair even though it is simple, it is a pretty amount of work 😁.

If decide to follow the latter option, little to no changes are required - you need to adjust the loading and placeholder template to reflect the load of both components and adjust the loading logic in the components' class to manage both dependencies. Notice the use of Promise.AllSettled static method to handle the dynamic loading of multiple dependencies:

function loadDeps() {
  return Promise.allSettled(
    [
      import("./user/projects/projects.component"),
      import("./user/achievements/achievements.component")
    ]
  );
}

@Component({
  template: `
    <div class="wrapper wrapper-xl">
      <ng-template #contentSlot />

      <ng-template #placeholderTpl>
        <p (inViewport)="onViewport()">
            Projects and Achievements will be rendered here...
        </p>
      </ng-template>

      <ng-template #loadingTpl>
        <h2>Projects</h2>
        <app-projects-skeleton />

        <h2>Achievements</h2>
        <app-achievements-skeleton />
      </ng-template>
      ...
    </div>
  `,
})
export class UserProfileComponent {
  ...
  async onViewport() {
    await delay(1000);
    this.depsState$.next('IN_PROGRESS');

    const [projectsLoadModule, achievementsLoadModule] = await loadDeps();
    if (projectsLoadModule.status == "rejected" || achievementsLoadModule.status == "rejected") {
      this.depsState$.next('FAILED');
      return;
    }

    await delay(3000);

    this.contentSlot.createComponent(projectsLoadModule.value.ProjectsComponent);
    this.contentSlot.createComponent(achievementsLoadModule.value.AchievementsComponent);

    this.depsState$.next('COMPLETE');
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we are still using the same template container/slot for inserting the components into the view, so as a result, we get this 👇:

Lazy loading of Projects and Achievements
Error handling of dependencies is project-specific and you are free to implement it as you think it works better for you.

This is all for the classic approach, a considerable amount of work isn't it? Now let's explore the modern approach.

Modern Template-Level Lazy Loading: Deferrable Views

Now, let's see how this new, modern API fits our showcase. For consistency's sake, we will use the same UserProfile component, thus having a close comparison with the classic APIs. As the article's introductory section notes, Angular 17 introduced Deferrable Views, a new API that moves the burden of classic APIs from developers to the framework - more specifically, the compiler.

Remembering the base points defined above, all that is needed to achieve the same result is the following template code 👇:

@Component({
  ...
  imports: [... ProjectsComponent],
  template: `
    <div class="wrapper">
       <app-details />
    </div>

    <div class="wrapper wrapper-xl">
       @defer (on viewport) {
         <app-projects />
       } @placeholder {
         <p>Projects will be rendered here...</p>
       } @loading {
          <app-projects-skeleton />
       } @error {
          <p>Oops, something went wrong!</p>
       }
    </div>
  `
})
export class UserProfileComponent {}
Enter fullscreen mode Exit fullscreen mode

Can you notice the template-driven approach? There is no imperative work for state management and async loading, thus having a code-free class component. The content to be lazy-loaded is specified inside the @defer block and the trigger is defined as a parameter after that (on viewport). Also, you declaratively define the placeholder, error, and loading templates using the naming-relevant template @blocks (no ng-templates) to properly mirror the state of the process in the template, with the placeholder template being the trigger element:

Deferrable loading of projects when in the viewport
But there is a small detail that you may have spotted already: since the Project component is on the template at the author's time, it needs to be imported into the imports array of the component's metadata just so template dependencies are detectable and reachable. But, the compiler is aware of that, so everything works as it should.

You may also notice the flickering issue occurring for the same reasons as before. As earlier, to fix it, you need to coordinate the time when either the placeholder or loading template is shown. To accomplish this, the @loading block accepts two optional params, minimum and after, as below:

@Component({
  ...
  template: `
    ...
    <div class="wrapper wrapper-xl">
       @loading (after 1s; minimum 3s) {
          <app-projects-skeleton />
       }
    </div>
    ...
  `
})
export class UserProfileComponent {}
Enter fullscreen mode Exit fullscreen mode

The parameters provided to the @loading block control when and how long it should be rendered on the view. In our case, parameters specify that the @loading block is to be shown one second after the loading process begins and remain visible for at least three seconds:

Deferrable loading of projects with no flickering

The deferrable views work only with standalone dependencies.

Triggers themselves are capable of accepting parameters, just like template blocks do. In our scenario, the viewport trigger accepts an optional parameter which is a DOM element that acts as the trigger element ( replacing the placeholder template). This enables dependencies to begin loading before the user scrolls to the area of the page where the loaded content will be rendered:

@Component({
  ...
  imports: [ProjectsComponent],
  template: `
    ...
    <span #triggerEl></span>
    ...

    <div class="wrapper wrapper-xl">
       @defer (on viewport(triggerEl)) {
         <app-projects />
       } 
       ...
    </div>
  `
})
export class UserProfileComponent {}
Enter fullscreen mode Exit fullscreen mode

The loading of dependencies now begins as the user scrolls down and the trigger element (span) reaches the viewport, see below 👇:

Early trigger of lazy loading of projects
That is easily verified, as you can see the loading template in the view when scrolling to the Projects section of the page. That's great 😎.

Lazy loading of multiple components

Up to now, we still have one last point to fulfill: lazy loading more than one component. To showcase, the Achievements component is ready to be loaded alongside the Projects component.

As with the classic approach, there are 2 paths to follow: either load them separately or together. With the new API, @defer block, both of these options are easy to implement 😎.

Loading them together 🚀

To load components together, the Achievements component needs to be imported to the imports array of component metadata, and then inserted into the @defer block, as below:

@Component({
  ...
  imports: [... ProjectsComponent, AchievementsComponent],
  template: `
     ...

    <div class="wrapper wrapper-xl">
       @defer (on viewport) {
         <app-projects />
         <app-achievements />
       } @placeholder () {
         <p>Projects and Achievements will be rendered here...</p>
       } @loading (after 1s; minimum 3s) {
          <h2>Projects</h2>
          <app-projects-skeleton />

          <h2>Achievements</h2>
          <app-achievements-skeleton />
       } @error {
          <p>Oops, something went wrong!</p>
       }
    </div>
  `
})
export class UserProfileComponent {}
Enter fullscreen mode Exit fullscreen mode

Also, the loading and placeholder templates are adjusted to reflect the loading of both components and nothing anymore👇:

Deferrable loading of projects and achievements

Loading them separately 🚀

To load components separately, each of them should be wrapped in its @defer block and define other associated blocks:

@Component({
  ...
  imports: [... ProjectsComponent, AchievementsComponent],
  template: `
     ...

    <div class="wrapper wrapper-xl">
       @defer (on viewport) {
          <app-projects  />
       } @placeholder () {
          <p>Projects will be rendered here...</p>
       } @loading (after 1s; minimum 3s) {
          <h2>Projects</h2>
          <app-projects-skeleton />
       } @error {
          <p>Oops, something went wrong!</p>
       }

       @defer (on viewport) {
          <app-achievements />
       } @placeholder () {
          <p>Achievements will be rendered here...</p>
       } @loading (after 1s; minimum 3s) {
          <h2>Achievements</h2>
          <app-achievements-skeleton />
       } @error {
          <p>Oops, something went wrong!</p>
       }
    </div>
  `
})
export class UserProfileComponent {}
Enter fullscreen mode Exit fullscreen mode

Compared with the classic approach, far less code is required. You can add as many @defer blocks as you need with little effort, and still be sure that it works perfectly fine 🤗:

Deferrable loading of projects and achievements (separately)
That's all for the modern approach. These are only a handful of the many features that Deferrable View offers. Check out these resources for a more comprehensive guide on deferrable views:

Conclusion

Lazy loading is a performance optimization technique adopted in web frameworks like Angular. Route-level lazy loading is very popular in the Angular community but template-level lazy loading is lacking popularity even though time has proven that existing APIs work very well. The amount of work, involvement of both component's class and template, and all of that imperative code makes them everything but not developer-friendly.

Angular 17, through the @block template syntax, deferrable views provide a modern, declarative, template-driven API, that can be used to defer the loading of portions of the template that may not ever be loaded until a future point in time. All you need to do is determine which setup of these APIs best suits your intended use case.

You can find and play with the final code here: https://github.com/ilirbeqirii/lazy-load-component

Special thanks to @kreuzerk and @eneajaho for review.

Thanks for reading!

I hope you enjoyed it 🙌. If you liked the article please feel free to share it with your friends and colleagues.
For any questions or suggestions, feel free to comment below 👇.
If this article is interesting and useful to you, and you don't want to miss future articles, follow me at @lilbeqiri, dev.to, or Medium. 📖

Top comments (5)

Collapse
 
jangelodev profile image
João Angelo

Hi Ilir Beqiri,
Your tips are very useful
Thanks for sharing

Collapse
 
jangelodev profile image
João Angelo

Hi Ilir Beqiri ,
Excellent content, very useful.
Thanks for sharing.

Collapse
 
lilbeqiri profile image
Ilir Beqiri

Thanks a lot ❤️

Collapse
 
railsstudent profile image
Connie Leung

Good write-up. Half way through the blog post, I was looking for deferrable views

Collapse
 
lilbeqiri profile image
Ilir Beqiri

Thanks a lot ❤️. Yeah, it is a bit lengthy until you reach the deferrable views section.