Home / Articles / Lazy-load a component in Angular without routing

Lazy-load a component in Angular without routing

November 25, 2021
9 min. read

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="john@example.com"
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:

Runtime error during the component lazy loading

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:

comments powered by Disqus

Also Read:

Vue’s primary motivation behind the introduction of Composition API was a cost-free mechanism for reusing logic between multiple components or apps. Is there a way to use that approach for AlpineJs without sacrificing its simplicity?
One of the most common web app patterns involves collecting data from a form and submitting it to a REST API or, the opposite, populating a form from data originating from a REST API. This pattern can easily be achieved in Alpinejs using the native javascript Fetch Api. As a bonus, I describe the fetch async version at the end of the article.
One of the most frequent requirements when writing AlpineJs components is the communication between them. There are various strategies for how to tackle this problem. This article describes the four most common patterns that help pass information between different Alpinejs components.