Provider Instances in Lazy Loaded Modules

July 20th 2018 Angular Ionic Framework

If you're developing applications for Ionic or Angular, you have probably already encountered static forRoot() and forChild() methods which you need to call when importing other modules, such as ngx-translate. However, you might not be fully aware of their significance, except for the fact that you should be using the former one when importing the sahred module into the main application module and the latter one when importing the shared module into lazy loaded modules.

This knowledge might suffice if you're only consuming modules. However, when developing your own shared modules, you'll likely need to learn more about them.

Let's create a simple module, consisting of:

  • a service with some state, which allows distinguishing between its instances:

      import { Injectable } from "@angular/core";
    
      @Injectable()
      export class SampleService {
    
        time: number;
    
        constructor() {
          this.time = Date.now();
        }
      }
    
  • a directive injecting the service state into the element it is applied to:

      import { SampleService } from './sample.service';
      import { Directive, ElementRef } from "@angular/core";
    
      @Directive({
        selector: '[sample]'
      })
      export class SampleDirective {
        constructor(element: ElementRef, service: SampleService) {
          let htmlElement = element.nativeElement as HTMLElement;
          htmlElement.innerText = `${service.time} ${htmlElement.innerText}`;
        }
      }
    
  • a module file connecting the two together:

      import { SampleService } from './sample.service';
      import { SampleDirective } from './sample.directive';
      import { NgModule } from '@angular/core';
    
      @NgModule({
        declarations: [ SampleDirective ],
        exports: [ SampleDirective ],
        providers: [ SampleService ]
      })
      export class SampleModule {}
    

In an Ionic application we can now import this shared module in the application module app.module.ts:

@NgModule({
  // ...
  imports: [
    BrowserModule,
    IonicModule.forRoot(MyApp),
    SampleModule
  ],
  // ...
})
export class AppModule {}

This allows us to use the directive in the default page home.html:

<p sample>HomePage</p>

By default, other pages will be placed in their own lazy loaded module, e.g. first.module.ts. To use the directive, the shared module needs to be imported in this module as well:

@NgModule({
  declarations: [
    FirstPage,
  ],
  imports: [
    IonicPageModule.forChild(FirstPage),
    SampleModule
  ],
})
export class FirstPageModule {}

The directive will be used on the lazy loaded page (e.g. first.html) in the same way:

<p sample>FirstPage</p>

As soon as we run the application and navigate to both the pages, we can see that they are using different instances of SampleService as different timestamps will be injected, e.g.:

1530972612188 HomePage
1530972624098 FirstPage

Why is this happening? Because of the way the Angular dependency injector works. For detailed explanation, you should read a great blog post by Thomas Hilzendegen. Here's the gist of it:

  • Eagerly loaded modules are all bootstrapped at the same time and therefore have a common dependency injector.
  • Lazy loaded modules are bootstrapped on demand. They have their own dependency injector which will create the instances for all providers declared inside them.

Only when a provider isn't declared at the lazy loaded module level, the dependency injector will fallback to the application dependency injector to resolve it at runtime and hence inject the same instance.

So, to fix our problem and have the same instance be used in all modules (even if they are lazily loaded), we need to declare our service only when injecting the module into the application module and not when injecting it into the lazy loaded modules. Having separate forRoot() and forChild() methods is the standard convention for achieving this:

import { SampleService } from './sample.service';
import { SampleDirective } from './sample.directive';
import { NgModule, ModuleWithProviders } from '@angular/core';

@NgModule({
  declarations: [ SampleDirective ],
  exports: [ SampleDirective ]
})
export class SampleModule {

  static forRoot(): ModuleWithProviders {
    return {
      ngModule: SampleModule,
      providers: [ SampleService ]
    };
  }

  static forChild(): ModuleWithProviders {
    return {
      ngModule: SampleModule
    };
  }
}

As you can see, the SampleService is not declared inside the NgModule decorator any more. Instead it is additionally provided as part of the forRoot() method which will be called by the application module. The forChild() method does not provide it, so that the lazy loaded modules will be forced to use the instance provided by the application dependency injector.

To fix our application, we need to change how SampleModule is imported in its modules:

  • The application module will call forRoot():

      @NgModule({
        // ...
        imports: [
          BrowserModule,
          IonicModule.forRoot(MyApp),
          SampleModule.forRoot()
        ],
        // ...
      })
      export class AppModule {}
    
  • And the lazy loaded page module will call forChild():

      @NgModule({
        declarations: [
          FirstPage,
        ],
        imports: [
          IonicPageModule.forChild(FirstPage),
          SampleModule.forChild()
        ],
      })
      export class FirstPageModule {}
    

If we run the application now, the same timestamp will be injected in both pages, proving that they are using the same instance of SampleService:

1530973974970 HomePage
1530973974970 FirstPage
Copyright
Creative Commons License