Lazy-load a component in Angular without routing
One of the most desirable features in Angular is to lazy load a component the time you need it. This approach provides many benefits to the loading speed of the application as it downloads only the required components when you need them. Furthermore, it is a very straightforward procedure through routing that is documented in the Angular docs. However, what if you do not want to use the router, or you want to lazy load a component programmatically through your code?
scaffolding a sample form app
To highlight that scenario, let’s create a minimal angular web app without routing with a button that shows a form when we click it. We will use, also the Angular Material to have a simple and beautiful design.
The application comprises two different components: the AppComponent
and the LazyFormComponent
.
The AppComponent
shows the main app, which contains a button that shows the LazyFormComponent
when pressed.
1@Component({
2 selector: "app-root",
3 template: `
4 <div style="text-align:center;margin-top: 100px;" class="content">
5 <h1>Welcome to lazy loading a Component</h1>
6 <button mat-raised-button color="primary" (click)="showForm = true">
7 Load component form!
8 </button>
9 <app-lazy-form *ngIf="showForm"></app-lazy-form>
10 </div>
11 `,
12 styles: [],
13})
14export class AppComponent {
15 public showForm = false;
16}
The LazyFormComponent
defines a simple reactive form with two inputs, a name and email, and a submit button:
1@Component({
2 selector: "app-lazy-form",
3 template: `
4 <form
5 [formGroup]="simpleForm"
6 style="margin:50px;"
7 fxLayout="column"
8 fxLayoutGap="20px"
9 fxLayoutAlign="space-between center"
10 (submit)="submitForm()"
11 >
12 <mat-form-field appearance="fill">
13 <mat-label>Enter your Name</mat-label>
14 <input matInput placeholder="John" formControlName="name" required />
15 <mat-error *ngIf="name?.invalid">{{ getNameErrorMessage() }}</mat-error>
16 </mat-form-field>
17 <mat-form-field appearance="fill">
18 <mat-label>Enter your email</mat-label>
19 <input
20 matInput
21 placeholder="[email protected]"
22 formControlName="email"
23 required
24 />
25 <mat-error *ngIf="email?.invalid">{{
26 getEmailErrorMessage()
27 }}</mat-error>
28 </mat-form-field>
29 <button type="submit" mat-raised-button color="accent">Submit</button>
30 </form>
31 `,
32 styles: [],
33})
34export class LazyFormComponent implements OnInit {
35 simpleForm = new FormGroup({
36 email: new FormControl("", [Validators.required, Validators.email]),
37 name: new FormControl("", [Validators.required]),
38 });
39
40 get name() {
41 return this.simpleForm.get("name");
42 }
43
44 get email() {
45 return this.simpleForm.get("email");
46 }
47
48 constructor() {}
49
50 ngOnInit(): void {}
51
52 getNameErrorMessage() {
53 if (this.name?.hasError("required")) {
54 return "You must enter a value";
55 }
56
57 return this.email?.hasError("email") ? "Not a valid email" : "";
58 }
59
60 getEmailErrorMessage() {
61 if (this.email?.hasError("required")) {
62 return "You must enter a value";
63 }
64
65 return this.email?.hasError("email") ? "Not a valid email" : "";
66 }
67
68 submitForm() {
69 if (this.email?.invalid || this.name?.invalid) return;
70 alert("Form submitted successfully");
71 }
72}
Finally, the AppModule
glue everything together and imports the corresponding modules mainly for the Angular Material:
1@NgModule({
2 declarations: [AppComponent, LazyFormComponent],
3 imports: [
4 BrowserModule,
5 MatButtonModule,
6 BrowserAnimationsModule,
7 ReactiveFormsModule,
8 MatFormFieldModule,
9 MatInputModule,
10 FlexLayoutModule,
11 ],
12 providers: [],
13 bootstrap: [AppComponent],
14})
15export class AppModule {}
The final result is:
Lazy loading a simple component
What if we want to load the LazyFormComponent
and their related material modules when we press the button and not the whole app?
We cannot use the route syntax to lazy load our component. Moreover, if we try to remove the LazyFormComponent
from AppModule
, the app fails because the Ivy compiler cannot find the required Angular Material modules needed for the form. This error leads to one of the critical aspects of Angular: The NgModule
is the smallest reusable unit in the Angular architecture and not the Component
, and it defines the component’s dependencies.
There is a proposal to move many of these configurations to the component itself, making the use of NgModule
optional. A very welcoming change that will simplify the mental model which programmers have on each angular application. But until that time, we need to create a new module for our LazyFormComponent,
which defines its dependencies.
For a NgModule
with one component, defining it in the same file with the component for simplicity is preferable.
So, the steps to display our lazy component is:
- define where we want to load our component in the template with the
ng-template
tag, - define its view query through
ViewChild
decorator, which gives us access to the DOM and defines the container to which the component will be added, - finally, dynamic import the component and add it to the container
The AppComponent
has transformed now as (the changed lines are highlighted):
1import {
2 Component,
3 ComponentFactoryResolver,
4 ViewChild,
5 ViewContainerRef,
6} from "@angular/core";
7
8@Component({
9 selector: "app-root",
10 template: `
11 <div style="text-align:center;margin-top: 100px;" class="content">
12 <h1>Welcome to lazy loading a Component</h1>
13 <button mat-raised-button color="primary" (click)="loadForm()">
14 Load component form!
15 </button>
16 <ng-template #formComponent></ng-template>
17 </div>
18 `,
19 styles: [],
20})
21export class AppComponent {
22 @ViewChild("formComponent", { read: ViewContainerRef })
23 formComponent!: ViewContainerRef;
24
25 constructor(private componentFactoryResolver: ComponentFactoryResolver) {}
26
27 async loadForm() {
28 const { LazyFormComponent } = await import("./lazy-form.component");
29 const componentFactory =
30 this.componentFactoryResolver.resolveComponentFactory(LazyFormComponent);
31 this.formComponent.clear();
32 this.formComponent.createComponent(componentFactory);
33 }
34}
For Angular 13
In Angular 13, a new API exists that nullifies the need for ComponentFactoryResolver
. Instead, Ivy creates the component in ViewContainerRef
without creating an associated factory. Therefore the code in loadForm()
is simplified to:
1export class AppComponent {
2 @ViewChild("formComponent", { read: ViewContainerRef })
3 formComponent!: ViewContainerRef;
4
5 constructor() {}
6
7 async loadForm() {
8 const { LazyFormComponent } = await import("./lazy-form.component");
9 this.formComponent.clear();
10 this.formComponent.createComponent(LazyFormComponent);
11 }
12}
Finally, we added the LazyFormModule
class:
1@NgModule({
2 declarations: [LazyFormComponent],
3 imports: [
4 ReactiveFormsModule,
5 MatFormFieldModule,
6 MatInputModule,
7 BrowserAnimationsModule,
8 FlexLayoutModule,
9 MatButtonModule,
10 ],
11 providers: [],
12 bootstrap: [LazyFormComponent],
13})
14export class LazyFormModule {}
Everything seems to work fine:
Lazy loading a complex component
The above approach works for the simplest components, which do not depend on other services or components. But, If the component has a dependency, for example, a service, then the above approach will fail on runtime.
Let’s say that we have a BackendService
for our form submission form:
1import { Injectable } from '@angular/core';
2
3@Injectable()
4export class BackendService {
5
6 constructor() { }
7
8 submitForm() {
9 console.log("Form Submitted")
10 }
11}
Moreover, this service needs to be injected in the LazyFormComponent
:
1constructor(private backendService: BackendService) {}
2
3 submitForm() {
4 if (this.email?.invalid || this.name?.invalid) return;
5 this.backendService.submitForm();
6 alert("Form submitted successfully");
7 }
But, when we try to lazy load the above component during runtime, it fails spectacularly:
Therefore, to make angular understand the need to load BackendService
, the new steps are:
- lazy load the module,
- compile it to notify Angular about its dependencies,
- finally, through the compiled module, we access the component and then add it to the container.
To access the component through the compiled module, we implement a helper function in the NgModule
:
1export class LazyFormModule {
2 constructor(private componentFactoryResolver: ComponentFactoryResolver) {}
3
4 getComponent() {
5 return this.componentFactoryResolver.resolveComponentFactory(
6 LazyFormComponent
7 );
8 }
9}
Therefore the code for lazy loading the LazyFormComponent
on loadForm()
function transforms to:
1 constructor(private compiler: Compiler, private injector: Injector) {}
2
3 async loadForm() {
4 const { LazyFormModule } = await import("./lazy-form.component");
5 const moduleFactory = await this.compiler.compileModuleAsync(
6 LazyFormModule
7 );
8 const moduleRef = moduleFactory.create(this.injector);
9 const componentFactory = moduleRef.instance.getComponent();
10 this.formComponent.clear();
11 this.formComponent.createComponent(componentFactory, {ngModuleRef: moduleRef});
12 }
For Angular 13
Again, Angular 13 has simplified the above API. So now, the NgModule
for the LazyFormComponent
does not require injecting ComponentFactoryResolver
. Therefore we only return the component:
1export class LazyFormModule {
2 constructor(private componentFactoryResolver: ComponentFactoryResolver) {}
3
4 getComponent() {
5 return LazyFormComponent
6 }
7}
Furthermore, we do not need to inject the Compiler
service because the compilation occurs implicitly with Ivy. So, instead of compiling the module, we only get the reference to it with the createNgModuleRef
function:
1 constructor(private injector: Injector) {}
2
3 async loadForm() {
4 const { LazyFormModule } = await import("./lazy-form.component");
5 const moduleRef = createNgModuleRef(LazyFormModule, this.injector)
6 const lazyFormComponent = moduleRef.instance.getComponent();
7 this.formComponent.clear();
8 this.formComponent.createComponent(lazyFormComponent, {ngModuleRef: moduleRef});
9 }
Passing values and listening events
What if we want to pass some values or listen to some events from our lazy loading component? We cannot use the familiar syntax for a defined component in a template. Instead of that, we can access them programmatically.
For example, we want to change the text of the submit button on LazyFormComponent
, and we want to be informed when the form is submitted. We add the required attributes, an Input()
attribute for the prop buttonTitle
and an Output()
for the formSubmitted
event:
1export class LazyFormComponent implements OnInit {
2 @Input()
3 buttonTitle: string = "Submit";
4
5 @Output() formSubmitted = new EventEmitter();
6
7 submitForm() {
8 if (this.email?.invalid || this.name?.invalid) return;
9 this.backendService.submitForm();
10 this.formSubmitted.emit();
11 alert("Form submitted successfully");
12 }
13}
The createComponent
function returns an instance of the component which we can set the props and listen to the events through their observables:
1formSubmittedSubscription = new Subscription();
2
3 async loadForm() {
4 const { LazyFormModule } = await import("./lazy-form.component");
5 const moduleFactory = await this.compiler.compileModuleAsync(
6 LazyFormModule
7 );
8 const moduleRef = moduleFactory.create(this.injector);
9 const componentFactory = moduleRef.instance.getComponent();
10 this.formComponent.clear();
11 const { instance } = this.formComponent.createComponent(componentFactory, {ngModuleRef: moduleRef});
12 instance.buttonTitle = "Contact Us";
13 this.formSubmittedSubscription = instance.formSubmitted.subscribe(() =>
14 console.log("The Form Submit Event is captured!")
15 );
16 }
17
18 ngOnDestroy(): void {
19 this.formSubmittedSubscription.unsubscribe();
20 }
You can check the complete sample solution in the GitHub repository here:
Or the Angular 13 version:
Code-splitting and lazy-load components have their uses in modern web development, and I think with the changes in Angular 13, it has been simplified a lot.
Share: