Lazy loading components with Angular 9+ just got a lot easier!
Previous to Angular v9
Previously, lazy loading a single component (not a lazy loaded module) in Angular took a bit of understanding the underlying APIs of Angular, and some might say, a bit of magic.
Angular version 9 introduced a new rendering engine called “Ivy”. This replaces the previous rendering engine, which was called “View Renderer”. With Ivy the APIs for Angular are simplified, making it much easier to compile components using ahead-of-time (AOT) compilations and to then lazily, and dynaically, load and create components during runtime.
Before Ivy and Angular version 9 we had to:
- Use the
NgModuleFactoryLoader
to load and compile the module, providing it an instance of the dependencyInjector
. - We then use the
resolveComponentFactory
method of an injectedComponentFactoryResolver
providing it with our component instance. - Finally, we can create and render the component via the
createComponent()
method ofViewContainerRef
in the DOM.
For the sake of brevity, I am not going to take a deep dive into the complexities of the code required for lazy load a component with Angular v2 to v8. Instead, I would refer you to the popular hero-loader module built by the team at HeroDevs.
Repository
In order to follow along, you need to checkout the v9-lazy-components branch of the repository:
git clone https://github.com/blove/angular-v9.git
git checkout v9-lazy-components
Demo
Existing Declarative Component
In this example, I currently have a PlanetComponent
instance that displays the details associated with a Planet
.
I use this component in order to display a person’s home planet.
For example, Luke Skywalker’s home planet it Tatooine.
Here is the template where I am currently implementing the PlanetComponent
, located at src/app/features/people/dialogs/person-home-planet-dialog/person-home-planet-dialog.component.html:
<h1 mat-dialog-title>{{ data.person.fields.name }}: Home Planet</h1>
<mat-dialog-content>
<swr-planet [planet]="planet | async"></swr-planet>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Close</button>
</mat-dialog-actions>
And, here is the PlanetComponent
class, which is located at src/app/features/people/presenters/planet/planet.component.ts:
@Component({
selector: 'swr-planet',
templateUrl: './planet.component.html',
styleUrls: ['./planet.component.scss'],
})
export class PlanetComponent {
/** The planet to display. */
@Input() planet: Planet;
}
The PlanetComponent
has a single input that accepts the Planet
to display.
Lazy Load Component with Angular v9
For the purpose of this exercise I want to lazy load the PlanetComponent
.
First, we need to remove the existing declared component, and we’ll replace this with an <ng-template>
element:
<h1 mat-dialog-title>{{ data.person.fields.name }}: Home Planet</h1>
<mat-dialog-content>
<ng-template></ng-template>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Close</button>
</mat-dialog-actions>
Next, we’ll access the ViewContainerRef
associated with the <ng-container>
using the @ViewChild()
decorator in our component class.
This is located in src/app/features/people/dialogs/person-home-planet-dialog/person-home-planet-dialog.component.ts:
export class PersonHomePlanetDialogComponent {
/** The view container reference for the planet template. */
@ViewChild(TemplateRef, { read: ViewContainerRef })
private planetTemplateViewContainerRef: ViewContainerRef;
}
A few things to note:
- First, we use the
@ViewChild()
decorator in order to create a view query for theTemplateRef
instance in our template, which we declared using the<ng-template>
element. - Second, we specify the
opts
optional second argument to the@ViewChild()
decorator function. This optional argument is an object, and one of the properties that we can specify is theread
property. - The
read
property enables us to specify a different token to return from the view query. In this case, I want a reference to theViewContainerRef
instance. - We’ll use the
planetTemplateViewContainerRef
class property shortly to instruct the rendering engine where to render our lazy loaded component.
The next step is to inject an instance of the ComponentFactoryResolver
class.
export class PersonHomePlanetDialogComponent {
/** The view container reference for the planet template. */
@ViewChild(TemplateRef, { read: ViewContainerRef })
private planetTemplateViewContainerRef: ViewContainerRef;
constructor(
private readonly componentFactoryResolver: ComponentFactoryResolver,
) {}
}
According to the documentation, the ComponentFactoryResolver
is:
A simple registry that maps Components to generated ComponentFactory classes that can be used to create instances of components.
The next steps are:
- Lazy load the component bundle using the dynamic
import()
function. This function requires the path to the module and returns aPromise
, that when resolved, provides the module. - Use the
ComponentFactoryResolver
to first get theComponentFactory
. - Then we’ll use the
createComponent()
method of ourViewContainerRef
to create and render the component in the DOM. - Finally, we can use the returned
ComponentRef
instance to modify the properties of the instance.
export class PersonHomePlanetDialogComponent {
/** The view container reference for the planet template. */
@ViewChild(TemplateRef, { read: ViewContainerRef })
private planetTemplateViewContainerRef: ViewContainerRef;
constructor(
private readonly componentFactoryResolver: ComponentFactoryResolver,
) {}
private lazyLoadPlanet(planet: Planet): void {
import('../../presenters/planet/planet.component').then(
({ PlanetComponent }) => {
const component =
this.componentFactoryResolver.resolveComponentFactory(
PlanetComponent,
);
const componentRef =
this.planetTemplateViewContainerRef.createComponent(component);
componentRef.instance.planet = planet;
},
);
}
}
Let’s quickly review the lazyLoadPlanet()
method:
- First, we use the
import()
function to dynamically import a module (ES6 module, not NgModule). - This returns a
Promise
that resolves with the module. - I’m using object destructuring to access the
PlanetComponent
class that is exported in the module. - Use the
resolveComponentFactory()
method of the injectedComponentFactoryResolver
class to get theComponentFactory
instance for thePlanetComponent
. - Use the
createComponent()
method of theplanetTemplateViewContainerRef
to create and render the component, which is appended toViewContainerRef
. - The
createComponent()
method returns aComponentRef
instance, which has aninstance
property that refers to the component instance. - Finally, we specify the
planet
property of ourPlanetComponent
instance to provide the necessary data for display the information of theplanet
.
Here is the complete source code that includes fetching the Planet
for the person
:
export class PersonHomePlanetDialogComponent implements OnDestroy, OnInit {
/** The view container reference for the planet template. */
@ViewChild(TemplateRef, { read: ViewContainerRef })
planetTemplateViewContainerRef: ViewContainerRef;
/** Unsubscribe from observable streams when the component is destroyed. */
private unsubscribe = new Subject();
constructor(
private readonly componentFactoryResolver: ComponentFactoryResolver,
@Inject(MAT_DIALOG_DATA) public data: { person: Person },
private readonly planetService: PlanetService,
) {}
ngOnDestroy() {
this.unsubscribe.next();
this.unsubscribe.complete();
}
ngOnInit() {
this.planetService
.getPlanetForPerson(this.data.person)
.pipe(
tap((planet) => this.lazyLoadPlanet(planet)),
takeUntil(this.unsubscribe),
)
.subscribe();
}
private lazyLoadPlanet(planet: Planet): void {
import('../../presenters/planet/planet.component').then(
({ PlanetComponent }) => {
const component =
this.componentFactoryResolver.resolveComponentFactory(
PlanetComponent,
);
const componentRef =
this.planetTemplateViewContainerRef.createComponent(component);
componentRef.instance.planet = planet;
},
);
}
}
And that’s how we lazy load a component using Angular version 9!
Lazy Loading Multiple Components into a Template
In the example above you might have noticed that we are using the TemplateRef
query with @ViewChild()
to access the <ng-template>
template ref.
If you are lazy loading multiple components into a template this strategy will not work.
The solution here is to use multipe <ng-container>
elements with template reference variables:
<ng-container #personContainer></ng-container>
<ng-container #planetContainer></ng-container>
I like to use the <ng-container>
element for this as to not pollute the DOM with <div>
(or other) elements.
Then, I can specify the template reference variable name to the @ViewChild()
query to obtain the ViewContainerRef
for each container:
export class PersonHomePlanetDialogComponent {
/** The view container reference for the person container. */
@ViewChild('personContainer', { read: ViewContainerRef })
private personViewContainerRef: ViewContainerRef;
/** The view container reference for the planet container. */
@ViewChild('planetContainer', { read: ViewContainerRef })
private planetViewContainerRef: ViewContainerRef;
}
Now I can lazy load components as necessary, and then create and render the appropriate component into the appropriate ViewContainerRef
.
Dependencies
What if your lazy loaded component has dependencies that need to be injected into the component class’s constructor()
function?
The answer is:
- If the dependency is already guaranteed to be loaded via the
providers
array in an@NgModule()
, or via theprovidedIn
property in class’s@Injectable()
decorator, then it should just work. Of course, if the dependency has not been provided, then dependency injection will fail with the error:NullInjectorError: No provider for XyzService!
. - If the dependency has not already been provided then we can specify the
providers
array in the@Component()
metadata of the lazy loaded component.
Here is an example of using the providers
property in the component metadata to specify a dependency for a lazy loaded component:
@Component({
selector: 'swr-planet',
templateUrl: './planet.component.html',
styleUrls: ['./planet.component.scss'],
providers: [FilmService],
})
export class PlanetComponent {
/** The planet to display. */
@Input() planet: Planet;
constructor(filmService: FilmService) {}
}
Note:
- Using the
providers
property in the@Component()
decorator we can instruct dependency injection to create a provider for this component (and at this level of the dependency tree). - We can now inject the dependency via the component class’s
constructor()
function.
Modules
What if your lazy loaded component relies on other modules?
For example, what if you need the FormsModule
and/or the ReactiveFormsModule
if your component is a form?
If we attempt to use NgForm
in our template we are going to get an error: error: No directive found with exportAs 'ngForm'
. The problem is that our component template relies on the publicly exported directives within theFormsModule
.
The answer is that we include an NgModule
in the same file as the component and specify the necessary imports
for the component:
@Component({
selector: 'swr-planet',
templateUrl: './planet.component.html',
styleUrls: ['./planet.component.scss'],
})
export class PlanetComponent {
/** The planet to display. */
@Input() planet: Planet;
}
@NgModule({
declarations: [PlanetComponent],
imports: [FormsModule, ReactiveFormsModule],
})
class PlanetComponentModule {}
Note:
- The
PlanetComponent
relies on both theFormsModule
and theReactiveFormsModule
. - We create an
NgModule
in the same file as the lazy loaded component. - We specify the
declaration
property with a reference to the component class; in the case thePlanetComponent
class. - We specify the
imports
property with the array of modules that need to be imported for the lazy loaded component.
Conclusion
With Angular version 9 and the new Ivy compilation and rendering pipeline we can lazy load, create and render Angular components. 👏👏👏