The Path to Lazy Loading in Angular

The Older Way: Dynamic Imports

In versions prior to Angular 17, lazy loading at the component level demanded a manual setup, typically using dynamic imports or configuring lazy routes. Here's an example of that older pattern:

@Component({
  template: "<ng-container #container />",
})
export class ParentComponent implements AfterViewInit {
  @ViewChild("container", { read: ViewContainerRef })
  container!: ViewContainerRef;

  async ngAfterViewInit(): void {
    const { LazyComponent } = await import("./lazy.component");
    this.container.createComponent(LazyComponent);
  }
}

Although it worked, this method came with its own set of drawbacks:

  • It required direct manipulation of the ViewContainerRef.
  • It offered minimal control over the exact conditions for loading.
  • It entangled lazy-loading logic with component business logic.

Introducing Deferrable Views

Core Principles

Deferrable Views are built on two main ideas:

  1. Trigger: Dictates the moment the component is inserted into the view.
  2. Prefetch: Dictates the moment the component's code is fetched from the server.

These two mechanisms are separate and can operate independently:

  • It's possible to fetch a component's code early but delay its display.
  • Alternatively, you can set the display to happen as soon as a certain condition is true, without prefetching.

Getting Started

Here's the most straightforward way to use a Deferrable View:

@Component({
  template: `
    @defer {
      <heavy-component />
    }
  `
})

With this default setup, the following occurs:

  • A separate, lazy-loaded chunk is created for heavy-component.
  • The chunk is fetched only when the browser is idle, which is after all other page assets have loaded.
  • The component is displayed as soon as its code has finished loading.

Key Constraints to Remember

When you're implementing Deferrable Views, there are a few important limitations:

  • This feature requires that all components involved are standalone.
  • The deferred component should not expose tokens or constants that are required by other eager parts of the app, as this would defeat the purpose of lazy loading.
  • The @defer block must be placed directly in the parent's template; it cannot be used with a ViewChild query.
  • Any components that the deferred component imports can be either standalone or declared in an NgModule.

Going Further with Advanced Features

Control Blocks

Deferrable Views include three distinct control blocks to manage the different stages of loading:

1. @placeholder

@defer {
  <heavy-chart />
} @placeholder {
  <loading-skeleton />
}
  • Displays static content in the main bundle while the deferred component is loading.
  • It's part of the eagerly loaded main bundle, but it could be in a different chunk if you have nested @defer blocks.
  • You can enforce a minimum display time: @placeholder (minimum 2s).
  • Any content inside this block is loaded eagerly.

2. @loading

@defer {
  <data-visualization />
} @loading {
  <spinner />
}
  • Shows content while the browser is actively downloading the chunk.
  • This block is also part of the main bundle.
  • It supports parameters for timing:
    • @loading (minimum 2s) to keep it visible for at least that duration.
    • @loading (after 1s) to delay its appearance.
    • You can even combine them: @loading (after 1s; minimum 2s).
  • Keep in mind that if loading is fast and you have an after delay, the @loading block might not be visible at all.

3. @error

@defer {
  <complex-component />
} @error {
  <error-state />
}
  • This block is not a catch-all for runtime errors, and it doesn't offer a way to retry the loading process after a network failure.
  • Just like @placeholder, the content here is loaded eagerly as part of the main bundle.

What's the Difference Between @placeholder and @loading?

Despite looking similar, @placeholder and @loading cover different phases of the deferred view's lifecycle. @placeholder is the initial visual, appearing right away before the browser even starts fetching the deferred code. On the other hand, @loading is visible only during the actual download process and disappears as soon as the content is ready. Simply put, @placeholder reserves the space, while @loading indicates the active fetch.

Types of Triggers

Built-in Triggers (using on *)

  1. idle (this is the default)
@defer (on idle) {
  <component />
}

// equivalent to:
@defer (on idle; prefetch on idle) {
  <component />
}
  1. viewport
// With placeholder
@defer (on viewport) {
  <infinite-scroll-content />
} @placeholder {
  <loading-indicator />
}

// Without placeholder
<div #title>Title</div>
@defer (on viewport(title)) {
  <infinite-scroll-content />
}
  1. interaction
// With placeholder
@defer (on interaction) {
  <large-component />
} @placeholder {
  <placeholder-component />
}

// Without placeholder
<div #title>Title</div>
@defer (on interaction(title)) {
  <large-component />
}
  1. hover
@defer (on hover) {
  <detailed-preview />
} @placeholder {
  <preview-card />
}
  1. immediate
@defer (on immediate) {
  <critical-notice />
}
  1. timer
@defer (on timer(5s)) {
  <delayed-content />
}

Important: It's not allowed to use the same trigger more than once within a single deferrable view.

Custom Triggers (using when *)

For more specific scenarios, you can use the when keyword to set your own conditions:

@Component({
  template: `
    @defer(when showDetails; prefetch when isNearBottom) {
    <detailed-view />
    }
  `,
})
export class MyComponent {
  showDetails = signal(false);
  isNearBottom = computed(() => this.scrollPosition() > 0.8);
}

Strategies for Better Performance

Grouping Multiple Components

@defer (on viewport) {
  <statistics-chart />
  <data-table />
  <export-options />
}
  • Each component is split into its own separate chunk for loading.
  • They are all loaded at the same time but exist as independent bundles.

Smart Prefetching Techniques

// Load it when we are on the viewport and show it when we interact
@defer (on interaction; prefetch on viewport) {
  <comments-section />
} @placeholder {
  <comments-preview />
}

// Load it when load variable is true and show it when show variable is true
@defer(when show; prefetch when load) {
  <large-component />
}

Addressing ViewChild and Defer Block Conflicts

Before Angular version 18.2.1, there was a known issue where deferrable views wouldn't function correctly if you referenced a heavy or third-party component that wasn't included in the parent component's imports.

This external reference would interfere with tree-shaking, making it impossible for the Angular compiler to correctly isolate and defer that component.

The problem can be fixed in a couple of ways:

  • Update to a Newer Version:

    • Upgrading to Angular 18.2.1 or a later release resolves this without any further action.
    • These versions contain the necessary fixes for deferrable views.
  • Use Type-Only Imports:

import { Component, computed, viewChild } from "@angular/core";
import {
  ChartComponent,
  type ChartComponent as ChartComponentType, // solution Angular < 18.2.1
} from "./chart.component";

@Component({
  selector: "app-parent",
  standalone: true,
  imports: [ChartComponent],
  template: `
    @defer (on viewport; on idle) {
    <app-chart #child />
    } @placeholder (minimum 1s) { ... }
  `,
})
export class ParentComponent {
  readonly chart = viewChild<ChartComponentType>("child");
  readonly chartId = computed(() => this.chart()?.id);
}

@for placement: inside or outside the defer block

For displaying a list of similar components, it's generally advised to put the @defer block on the outside of the @for loop. This results in fewer embedded views and is more efficient. On the other hand, if you have a loop where each item might be a different heavy component based on a condition, then it's better to use deferrable views inside the @for loop.

@for (item of items) {
  @defer {
    <heavy-component />
  } 
}

@defer {
  @for (item of items) {
    <heavy-component />
  }
}

The decision you make here has an impact on runtime performance because it changes the number of embedded views and deferrable views that Angular has to track and manage.

For a deeper dive into the technical aspects, you can read this post by Matthieu Riegler.

Considerations for Server-Side Rendering

If you're using Deferrable Views in conjunction with Server-Side Rendering, there are some important factors to keep in mind:

  • Browser-specific events like interaction or hover won't work during the initial server render.
  • The server will only send the content inside the @placeholder block in its HTML response.
  • The triggers will only become active after the page has been hydrated on the client.
  • It's wise to think about what you show in your initial loading state when no triggers can fire yet.

Incremental Hydration

The concept of Incremental Hydration expands on what @defer does, and was introduced as an experimental feature in v19. Jessica Janiuk proposed an RFC that uses @defer's mechanisms to manage when components get hydrated.

Here's a look at how it works internally:

  • Deferrable views act as boundaries for hydration.
  • The server first sends static HTML for all components, including those marked for deferral.
  • On the client, components are hydrated only when their specified triggers activate. The process looks like this:
@defer (hydrate on viewport) {
  <component />
}

// On viewport
@defer (hydrate when condition) {
  <component />
}

// When condition met (static only)
@defer (hydrate never) {
  <component />
}

Recommendations for Best Use

Improving Performance

  • Try to position deferrable views outside of loops to minimize overhead.
  • Utilize prefetching thoughtfully to improve the perceived speed.
  • Keep the content in your placeholder blocks simple and lightweight to avoid blocking.

Enhancing User Experience

  • Make sure to include placeholder content that is informative and meaningful to the user.
  • Put thought into when to show loading indicators for the best effect.
  • Ensure your error block communicates problems clearly to the user.

Structuring Your Code

  • Cluster related deferred components together for better organization.
  • Aim for trigger logic that is simple and straightforward to maintain.
  • Make your loading strategies clear, perhaps by documenting them in code comments.

Wrapping Up

Deferrable Views are a major improvement in how we handle lazy loading in Angular, offering:

  • A much finer level of control over when and how components load.
  • A better user experience, thanks to integrated loading and placeholder states.
  • Simpler and more readable, declarative code.

Now that it's stable in Angular 18, it gives us a solid basis for creating faster, more interactive applications.

One final distinction to note is that Deferrable Views allow you to lazy-load a specific section of a component's template, whereas traditional lazy loading handles entire components. Also, Deferrable Views work independently of the Router.

Thanks for your time 🙏

If you found this helpful, please share the Angular enthusiasm! 💜

If you really liked it, share it within your community, your tech circle, and anyone else who might find it useful! 🚀👥

Thanks for being a part of this Angular community! 👋😁


Understanding Angular Deferrable Views — figure 1