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 @NgModule
s. 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
).
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.
- First: Have
CounterService
in lazy module only -- no way to get into the eagerly loaded component! Error!
- Second: Have
CounterService
in a shared module that provides providers everywhere it's imported. Duplicate services!
- Third: Have
CounterService
in a shared module that only provides providers via forRoot
Happiness
- 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!
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]:
- 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.
- 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
...
- One avenue via
forRoot
that gives us the ModuleWithProviders
.
- 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 |
1 | import { NgModule } from '@angular/core'; |
1 | import { NgModule, ModuleWithProviders } from '@angular/core';
|
2 | import { CounterService } from './counter.service'; |
2 | import { CounterService } from './counter.service'; |
3 |
|
3 |
|
4 | @NgModule({ |
4 | @NgModule({})
|
5 | providers: [ CounterService ] |
|
|
6 | }) |
|
|
7 | export class SharedModule { |
5 | export 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 | }) |
10 | export class AppModule { } |
10 | export 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 |
1 | import { NgModule } from '@angular/core'; |
1 | import { NgModule } from '@angular/core'; |
2 | import { SharedModule } from '../shared/shared.module'; |
2 | import { SharedModule } from '../shared/shared.module'; |
3 | import { LazyComponent } from './lazy.component'; |
3 | import { LazyComponent } from './lazy.component'; |
4 | |
4 | |
5 | import { Routes, RouterModule } from '@angular/router'; |
5 | import { Routes, RouterModule } from '@angular/router'; |
6 | |
6 | |
7 | const routes: Routes = [ |
7 | const 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 | }) |
18 | export class LazyModule {} |
18 | export 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
- 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.
- 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.
Labels: angular, howto