Pages

Tuesday, May 05, 2020

When and why you use forRoot in Angular, aka "Dependency Injection for Children"

if you mess around long enough in Angular, you're going to see a module other than RouterModule -- that is, a module that's under your control -- with a static forRoot method. It's about this time that you look up the docs for NgModule and discover that, contrary to your expectations from RouterModule's own forRoot, forRoot isn't a part of all @NgModules. It's just something RouterModule contains whose return value can be used in a module's imports.

What the heck is going on here?

Well, the quick answer is that chances are very good it's because you're lazy loading a module. Lazy loading is when you spare the user from loading all of your code until they ask for it. And they ask for it by trying to access a route that requires it.

That's the when, but that's not the "why" we use forRoot. Why is forRoot used?

Answers are a dime a dozen on the net, and I had a hard time getting it all straight until I ran into a particularly good description in this Angular training book from Rangle.io.

Let's run through the topic with a little help from Rangle quickly.


Different modes of injection

In an example in the Rangle book, we've got an Angular app set up to use two almost identical components -- but one is eagerly loaded (and called EagerComponent) and the other is loading lazily (you got it -- LazyComponent).

Each of the components has a very simple CounterService injected into their constructors. The CounterService manages a trivial state: a counter.

@Injectable()
export class CounterService {
  counter = 0;
}

From in the Angular docs, we learn that when a module is lazy loaded, it creates its own "child" ModuleInjector since the module wasn't around to get hooked up to the original injector. The new injector is created a little like the Object.assign process. If a service was in the root injector, it'll be copied over to the child injector too. But if the child has the same service listed in its definition, the root's version will be replaced with a new one scoped to the child.

That means that, if we don't do anything to anticipate it, the CounterService, a service we expect to be a singleton, will be created twice, and its state won't be shared between the root, or parent, context (the context that includes EagerComponent) and the child (the context which contains LazyComponent).

a picture of the child

Here are the scenarios the Rangle Angular 2 training book covers. The results prove, give or take, our claim: Importing a provider in a lazy loading child module creates a second instance of the service with its own state.

Link to Rangle scenario

Lazy module providers

Lazy imports

AppModule imports

SharedModule used?

SharedModule providers

Result

First

[CounterService]

N/A

N/A

No

N/A

Error

Second

N/A

SharedModule

SharedModule

Yes

[CounterService]

Two injectors, two service instances

Third

N/A

SharedModule

SharedModule
.forRoot()

Yes

None!

Singleton injector for CounterService

To round things out, there's also a "Fourth" possibility, though it isn't any more viable possibility than the first.

  1. First: Have CounterService in lazy module only -- no way to get into the eagerly loaded component! Error!
  2. Second: Have CounterService in a shared module that provides providers everywhere it's imported. Duplicate services!
  3. Third: Have CounterService in a shared module that only provides providers via forRoot Happiness
  4. Fourth: Have CounterService in the main app module only, unshared -- no way to import into the lazy module's constructor. Error!

From duplicated to shared services

Let's look a little closer at what had to change to get our lazy loaded module's component to use the same service that was set up in the root injector's when we eagerly loaded our first component.

NOTE: If you'd like to look through what the code looks like, this medium post was nice enough to include a stackblitz with code that exactly matches the examples for the pages in the Rangle book that cover dependency injection that we linked to, above!

dilbert comic showing the importance of actual code

You can take a look at the stackblitz here, though we'll run through the high notes here.

And then I've extended that code with more StackBlitzes for different scenarios that can be found here:

If you review the table, above, there are two changes that needed to be made to go from our Second scenario to our Third, which will brings us from two CounterService instances to one singleton shared service from the root injector [with a reference to the same service in the child injector too]:

  1. You need to pull the CounterService out of the SharedModule's providers (see the "SharedModule providers" column in table) so the service isn't automatically registering with each module's import.
  2. You need to create a forRoot method to, in effect, remedy the loss of the CounterService provider from step 1 when it's imported into the root module.

That is, our root module needs the provider for the CounterService. But our lazy/child module can't know CounterService is a provider or it'll try to create one of its own.

In other words, we create two avenues to import the same SharedModule...

  1. One avenue via forRoot that gives us the ModuleWithProviders.
  2. The other that's is our conventional import, but now with the SharedModule's providers removed! It's, in effect, now a "ModuleWithOUTProviders".

Let's go to the video tape...

Here's SharedModule. Old version on the left, and new version, now with a static forRoot method, on the right...

SharedModule Second Scenario SharedModule Third Scenario
1import { NgModule } from '@angular/core'; 1import { NgModule, ModuleWithProviders } from '@angular/core';
2import { CounterService } from './counter.service'; 2import { CounterService } from './counter.service';
3 3
4@NgModule({ 4@NgModule({})
5  providers: [ CounterService ]
6})
7export class SharedModule { 5export class SharedModule {
6  static forRoot(): ModuleWithProviders {
7    return {
8      ngModule: SharedModule,
9      providers: [CounterService]
10    };
11  }
8} 12}

Note that the providers section was removed from SharedModule, but it was stealthily moved to the new forRoot method.

Aside: It's interesting to keep in mind a lesson we can learn from this answer at stackoverflow.com:

By convention, the forRoot static method both provides and configures services at the same time. It takes a service configuration object and returns a ModuleWithProviders.

Emphasis there is mine. BY CONVENTION, you use forRoot. What's important is that you're returning a ModuleWithProviders<T>. NgModule.imports takes a strange combination of types, but note that ModuleWithProviders<> is explicitly called out as one:

imports: Array<Type<any> | ModuleWithProviders<{}> | any[]>

As long as you're putting a ModuleWithProviders<T> into the imports array, NgModule doesn't care how you got it. You can use good ole MyModule.AwopBopAhLooWop() if you want to; the name of the function doesn't matter. The name is a convention only, suggested by RouterModule.forRoot which, you guessed it, also returns ModuleWithProviders<T>, where, in this case, returns RouterModule for T.


And then here are the related changes in AppModule, which, as you can see, is only that we no longer import the module directly, but import the module WITH ITS PROVIDERS, which is what ModuleWithProviders<SharedModule> gives us.

This name, ModuleWithProviders, is not a coincidence. You can think of the direct module as a "ModuleWithOUTProviders" -- which isn't really a thing, but is a good mnemonic.

AppModule Second AppModule Third
1@NgModule({ 1@NgModule({
2  imports:      [ 2  imports:      [
3    BrowserModule, 3    BrowserModule,
4    SharedModule, 4    SharedModule.forRoot(),
5    RouterModule.forRoot(routes) 5    RouterModule.forRoot(routes)
6  ], 6  ],
7  declarations: [ AppComponent, EagerComponent ], 7  declarations: [ AppComponent, EagerComponent ],
8  bootstrap:    [ AppComponent ] 8  bootstrap:    [ AppComponent ]
9}) 9})
10export class AppModule { } 10export class AppModule { }
11 11

Here, we get a ModuleWithProviders from forRoot to add to the root module's imports. So it's getting essentially what it was getting before, just after it's been laundered by the forRoot method.


And then, finally, the changes in LazyModule.

This is a trick question.

LazyModule Second LazyModule Third
1import { NgModule } from '@angular/core'; 1import { NgModule } from '@angular/core';
2import { SharedModule } from '../shared/shared.module'; 2import { SharedModule } from '../shared/shared.module';
3import { LazyComponent }   from './lazy.component'; 3import { LazyComponent }   from './lazy.component';
4  4 
5import { Routes, RouterModule } from '@angular/router'; 5import { Routes, RouterModule } from '@angular/router';
6  6 
7const routes: Routes = [ 7const routes: Routes = [
8  { path: '', component: LazyComponent } 8  { path: '', component: LazyComponent }
9]; 9];
10  10 
11@NgModule({ 11@NgModule({
12  imports: [ 12  imports: [
13    SharedModule, 13    SharedModule,
14    RouterModule.forChild(routes) 14    RouterModule.forChild(routes)
15  ], 15  ],
16  declarations: [LazyComponent] 16  declarations: [LazyComponent]
17}) 17})
18export class LazyModule {} 18export class LazyModule {}
19  19 

Don't see any changes? That's because there aren't any! Now that there are no providers in the module, the lazy loaded module gets our "ModuleWithOUTProviders" type by default.

That is, as detailed in this StackOverflow answer:

The common pattern to achieve that is not to expose providers directly on the @NgModule declaration but in a static forRoot function (the name is not mandatory, it's a convention)


Next Level Dependancy Injection for Children

So for some reason, this has been one of the more difficult posts I've put together, so I'm going to cut scope a bit. But the next level topics would include a few interesting follow-ons, like...

There is one way to signal that we're intentionally importing the SharedModule withOUT providers: Use forChild. RouterModule does this, for instance, and it's probably responsible for all the third-party forRoot. Why not forChild too?

The CLI also adds RouterModule.forChild(routes) to feature routing modules. This way, Angular knows that the route list is only responsible for providing additional routes and is intended for feature modules. You can use forChild() in multiple modules.

The forRoot() method takes care of the global injector configuration for the Router. The forChild() method has no injector configuration. It uses directives such as RouterOutlet and RouterLink.

That does exactly what you'd expect. If forRoot is ModuleWithProviders, this is a ModuleWithProvider without any providers [sic]!

It would be fun to write two variations of the StackBlitzes, above

  1. One that used forChild and forRoot where we'd change the LazyModule trivially so that it called a new SharedModule.forChild() function instead of importing the SharedModule directly.
  2. Another that used the default SharedModule for the AppModule (the root) and a forChild for the LazyModule to see what trouble you could get in backwards. I can't honestly tell if you would or not. If the default has providers and forChild doesn't, aren't you okay? Or does the providers array in an NgModule have some extra voodoo that means only forRoot works by itself?

And just to make things more complicated, there is yet another way out of this conundrum:

Set the providedIn property of the @Injectable() to "root"....

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class UserService {
}

So that's fun.

The bottom line, however, remains the same. If you don't want lazy-loaded module specific instances of services but want to only use the root version as a singleton everywhere, you have to remove the services from your child modules' provider array and inject them in the root module with something -- convention dictates forRoot -- that returns a ModuleWithProviders.

Get it? A module. With providers. Because in the children, you're just returning the module "without providers" when it's imported. The providers come from the root.

Programming is Hard (c) 1842.