A lot of Angular modules need to be imported with a static forRoot()
function, via this function it allows us to configure the module.
An example, perhaps the most known, is the Angular Router Module that needs a collection of routes, another example is the NgRx Store that needs the root reducers of the application.
Internally, these modules will initialize the needed services and it will set up the orchestration between these services to be able to do its job.
But sometimes we make the mistake to use the forRoot()
function more than once throughout an application. We might not notice it when this happens but often it is the cause of unexpected behavior, which is sadly hard to debug.
The reason why this is a problem is that these modules must be treated as singletons.
When a module is being lazy-loaded and is also using the forRoot()
function, the lazy-loaded module will create a second instance of the module services. These instances are used in the context where they are created. A lazy-loaded module will use the instances it has created instead of using the root instances.
If we take the forRoot()
function of the NgRx StoreModule
as an example to set up the NgRx Store. It would mean that we end up with multiple store instances, all configured differently. If a feature module would dispatch an action it wouldn't reach the "real" root reducer, it will only reach the reducers configured by the lazy module. If the same action is dispatched from within the root module, it does reach the "real" root reducer.
A second problem is that when the same module is eagerly loaded multiple times when Angular is bootstrapping the application. Only one module will be registered and used, depending on how the modules are loaded. This could lead to a misconfigured module if you're not aware that this is happening.
Personally, I would waste a lot of time to be able to track and solve the problems caused by it and in this post, we'll look into a solution to prevent this.
Why is it bad if a shared module provides a service to a lazy-loaded module?
An example
For instance, imagine we have a ThemeModule
to set a theme of an application.
The theme of the application can be set with ThemeModule.forRoot(color)
and is configured at the start of the project.
Some time passes and our ThemeModule
gets extracted to a library to make it reusable in multiple applications, our ThemeModule
is such a great success that it's heavily used across multiple codebases. Suddenly it happens, in our application our theme color is magically changed when we import another module, we're now in one big color-fiesta, all because we didn't guard the ThemeModule
.
So, how can we avoid this?
The answer to this question are root guards and in the next code snippets, we'll see how we can set them up.
Show me the code
Let's implement the ThemeModule
first, by adding a static forRoot()
function to be able to set up the ThemeModule
.
@NgModule()
export class ThemeModule {
static forRoot(themeConfig: ThemeConfig): ModuleWithProviders<ThemeModule> {
return {
ngModule: ThemeModule,
providers: [{ provide: Theme, useValue: themeConfig }],
}
}
}
The above snippet creates a Theme
with the themeConfig
parameter, the Theme
looks as follows.
@Injectable()
export class Theme {
constructor(config: ThemeConfig) {}
}
To use the ThemeModule
we can use the forRoot()
function in the AppModule
.
@NgModule({
imports: [ThemeModule.forRoot({ color: '#dd0031' })],
})
export class AppModule {}
Our ThemeModule
is now ready to use but if it's imported for a second time, it's also creating a second instance of the theme.
This has the outcome that when the feature module is loaded, the module will use this theme instead of the root theme.
This can be prevented with 3 easy steps
1. Create an InjectionToken
export const THEME_ROOT_GUARD = new InjectionToken<void>(
'Internal Theme ForRoot Guard',
)
First of all, we need to create an InjectionToken
for our guard.
2. Provide the ROOT_MODULE_GUARD
in the providers of the module
@NgModule()
export class ThemeModule {
static forRoot(themeOptions): ModuleWithProviders<ThemeRootModule> {
return {
ngModule: ThemeRootModule,
provides: [
{
provide: Theme,
useFactory: createTheme,
deps: [themeOptions],
},
{
provide: THEME_ROOT_GUARD,
useFactory: createThemeRootGuard,
deps: [[Theme, new Optional(), new SkipSelf()]],
},
],
}
}
}
export function createThemeRootGuard(theme) {
if (theme) {
throw new TypeError(
`ThemeModule.forRoot() called twice. Feature modules should use ThemeModule.forFeature() instead.`,
)
}
return 'guarded'
}
With the factory function createThemeRootGuard
we provide a value for the THEME_ROOT_GUARD
token.
This factory function expects a theme
argument to check if the theme is already created, and if it's it will throw an error.
With deps
we can provide the Theme
, the first time the function is called the Theme
isn't initialized yet so we use Optional
to mark the parameter as optional otherwise the Dependency Injection container would throw an error because it isn't able to find a value for Theme
.
To not instantiate the Theme
we can use SkipSelf
. If we wouldn't do this, we would directly end up with 2 instances of Theme
.
More information on Optional
SkipSelf
can be found on angular.io.
3. Inject the guard token in the module
@NgModule()
export class ThemeModule {
constructor(
@Optional()
@Inject(THEME_ROOT_GUARD)
guard: any,
) {}
}
We inject the InjectionToken
in ThemeModule
to create the THEME_ROOT_GUARD
when the module is imported.
Every time a new instance of the ThemeModule
is created, the createThemeRootGuard
factory function will be called to create the THEME_ROOT_GUARD
. The second time this occurs, the parameter theme
will have a value and will thus result in an exception.
By doing this we can guard the developers using the ThemeModule
by not accidentally importing our module more than once via the forRoot()
function.
We make it very clear we didn't expect this to happen, and we can save some time and frustrations for the developers using our module if our module is badly used.
A second solution but different
There's a second solution to treat your module as a singleton which requires less setup but behaves a bit differently.
If we inject the ThemeModule
in itself, we can simply check if the ThemeModule
is already initialized.
@NgModule()
export class ThemeModule {
constructor(@Optional() @SkipSelf() themeModule: ThemeModule) {
if (themeModule) {
throw new TypeError(`ThemeModule is imported twice.`)
}
}
}
More information on this solution can be found in the Angular docs
The difference
A drawback to the second approach is that if we want to add the ability to load the ThemeModule
for a second time with for example a forChild
function this would throw the same error.
This happens because we have the guard in the constructor of the module. In comparison to the first solution where we have the guard inside the createThemeRootGuard()
function which is only provided in the forRoot()
function, meaning that it will only be checked if we import the module with forRoot()
.
Depending on your use case you can choose the best solution to fit your needs.
For a real-world example, you can take a look at the implementation of the Angular Router.
Follow me on Twitter at @tim_deschryver.