It’s time to revisit our Angular engine room. Cover photo by Gregory Butler on Pixabay
Original publication date: 2019-02-11.
NgModuleis often cited as one of the most puzzling pieces of the Angular framework.The good news is that Angular is heading toward a future where Angular modules (
NgModules) become less necessary, or entirely obsolete.In an upcoming Angular release, Ivy will let us bootstrap or mount components directly into the DOM without any Angular modules. We will also be able to reference other components, directives, and pipes inside our templates without relying on Angular modules to resolve them.
Those capabilities aren’t here yet, but we can start preparing now to make the transition smoother.
In the current Angular generation, a component can and must only be declared in a single
NgModule. The declarables (components, directives, and pipes) that a component can use are determined at compile time, derived from the metadata of the Angular module where that component is declared.Transitive module scope
Every Angular module carries a transitive module scope, which is computed at compile time. This transitive scope is actually split into two parts: a transitive compilation scope and a transitive exported scope.
Transitive exported scope
The transitive exported scope consists of all declarables that an Angular module explicitly lists in the
--re-export--> When that happens, the exported scopes of those re-exported modules are merged into this Angular module’s transitive exported scope.exportsoption of its metadata. An Angular module can also re-export other Angular modules by including them in that same option.Transitive compilation scope
The transitive compilation scope of an Angular module is the set of declarables that any component declared by that module is allowed to use inside its template.
These components, directives, and pipes are drawn from the transitive exported scopes of the Angular modules listed in the
importsoption of this module’s metadata.The transitive hero module compilation scope
Let’s walk through an example of a transitive module scope.
// hero-list.component.ts import { Component } from '@angular/core'; import { HeroService } from './hero.service'; @Component({ selector: 'app-hero-list', template: '<app-hero *ngFor="let hero of heroes$ | async"></app-hero>', }) export class HeroListComponent { heroes$ = this.heroService.getHeroes(); constructor(private heroService: HeroService) {} }// hero.component.ts import { Component } from '@angular/core'; @Component({ selector: 'app-hero', template: ` <span *ngIf="type === 'superhero'"> 🦸 </span> <span *ngIf="type === 'supervillain'"> 🦹 </span> {{ name }} `, }) export class HeroComponent { @Input() name: string; @Input() type: string; }// hero.module.ts import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { HeroComponent } from './hero.component'; import { HeroListComponent } from './hero-list.component'; @NgModule({ declarations: [HeroComponent, HeroListComponent], imports: [CommonModule], }) export class HeroModule {}The hero and hero list components declared by the same Angular module.
Here,
HeroModuledeclares two components. Both components share the same transitive compilation scope.
Figure 1. The transitive compilation scope of the hero module.Figure 1 shows the transitive compilation scope of
HeroModule. The area marked in red containsHeroComponent,HeroListComponent, and all declarables exported byCommonModule.Since
HeroListComponentis compiled within the context ofHeroModule’s transitive compilation scope, it can renderHeroComponentinstances using<app-hero>tags. That works because both components are declared insideHeroModule.The hero list component also makes use of
AsyncPipethrough its pipe name,async. This pipe is available becauseHeroModuleimportsCommonModule.Similarly, the
NgForOfstructural directive appears in the hero list template even though it’s declared byCommonModule. The hero component, meanwhile, relies on theNgIfstructural directive to conditionally show content in its own template.Local component scope
The Angular Ivy rewrite may bring back component-level scope for declarable dependencies. This concept is referred to as local component scope.
Angular has actually supported local component scope before. The last version to include it was Angular 2 RC5. At that time, the
Componentdecorator factory accepted thedirectivesandpipesoptions.Angular Pull Request #27841 and the Angular Ivy Proof of Concept Demo from Angular team member Minko Gechev hint that the
Componentdecorator factory might gain an option calleddeps, accepting an array or nested array of declarables used within the component template. The final API and implementation could still change.
Figure 2. The local component scopes of the hero list component and the hero component.Figure 2 illustrates what the local component scopes of the hero list and hero components would look like if they were converted to standalone components using the syntax proposed in Pull Request #27841. In that scenario, a hero Angular module would be unnecessary, as would the
CommonModule.With Angular Ivy, we’ll be able to mount a component independently of any
NgModule. Ivy will also allow us to lazy-load and render a component without an Angular module or the Angular Router.Single component Angular modules
Standalone components with local scope aren’t possible yet, but there’s a way to start preparing now: SCAMs (single component Angular modules).
For every component, we set up an
NgModulethat imports only the declarables that particular component needs. Likewise, it declares and exports only that single component.
// cart-button.component.ts import { Component } from '@angular/core'; @Component({ selector: 'cart-button', template: ` <button mat-icon-button type="button" (click)="onClick()"> <mat-icon aria-label="Add to shopping cart">shopping_cart</mat-icon> </button> `, }) export class CartButtonComponent { onClick(): void { this.addToShoppingCart(); } private addToShoppingCart(): void { // (...) } }// cart-button.module.ts import { NgModule } from '@angular/core'; import { MatButtonModule, MatIconModule } from '@angular/material'; import { CartButtonComponent } from './cart-button.component'; @NgModule({ declarations: [CartButtonComponent], exports: [CartButtonComponent], imports: [MatButtonModule, MatIconModule], }) export class CartButtonModule {}A SCAM (single component Angular module).
Admittedly, this takes a little extra effort, but it’s a pattern I’ve already been using in most places anyway. It helps keep an up-to-date inventory of declarable dependencies, which in turn helps keep the bundle size small.
When reviewing a SCAM, you only need to look at a single component to decide whether a particular Angular module import is actually being used.
SCAMs also come in handy for testing, because they import precisely the declarables required by the component under test.
They might also prove useful when working with the Bazel build system. Assigning one Bazel package to each Angular module makes the Bazel setup and build process considerably smoother.
Transitioning to standalone components
Looking ahead, there may come a time when a component and its SCAM are merged into a single standalone component. This would involve relocating the dependencies that the
NgModulecurrently declares into thedepsproperty of theComponentdecorator. The shift would also mean changing those dependencies from Angular module references to direct references of the declarables actually used in the template.// cart-button.component.ts import { Component } from '@angular/core'; import { MatButton, MatIcon } from '@angular/material'; @Component({ deps: [MatButton, MatIcon], selector: 'cart-button', template: ` <button mat-icon-button type="button" (click)="onClick()"> <mat-icon aria-label="Add to shopping cart">shopping_cart</mat-icon> </button> `, }) export class CartButtonComponent { onClick(): void { this.addToShoppingCart(); } private addToShoppingCart(): void { // (...) } }An example of a standalone component.
Consider
MatButtonModulebeing replaced byMatButtondirectly. As it stands now, Angular Material has not yet been compiled with Ivy and published in standalone form. This is expected to be a common scenario among many third-party libraries for the foreseeable future.Luckily, Angular Ivy was designed with this transition in mind. Ivy ships with two separate compilers. The primary one is the Ivy compiler, executed via the
ngtscprocess. This is essentially a lightweight wrapper around the TypeScript compiler which takes Angular decorators and converts them into metadata that gets stored as static class properties.The second compiler included with Ivy is the Angular Compatibility Compiler, or
ngccfor short. This tool allows us to convert pre-Ivy libraries located innode_modulesby executingivy-ngcceither as an NPM script or through NPX (i.e.,npx ivy-ngcc). The most sensible place to run this conversion is in thepostinstallNPM hook.Evaluating standalone components
When performing integration tests on our Angular components using the standard testing utilities, we currently have the ability to swap out view child components by introducing fake components that share the same selectors within the Angular testing module.
With the introduction of standalone components, however, view child components are now specified directly in the
Componentdecorator. This means the Angular team will need to introduce a new API that allows us to override these locally scoped declarables during test execution.Launching a standalone component
Ever since Angular 2, starting an Angular application has involved creating an Angular module that includes a
bootstrapoption pointing to a root component—typically, this is done with anAppModuleand anAppComponent. We have also grown accustomed to setting up a platform and bootstrapping theAppModulewithin our main entry file.// app.component.ts import { Component, OnInit } from '@angular/core'; @Component({ selector: 'pre-ivy-app', template: ` <h1>Hello, {{ name }}!</h1> `, }) export class AppComponent implements OnInit { name: string = 'World'; ngOnInit(): void { this.name = 'Angular'; } }// app.module.ts import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { AppComponent } from './app.component'; @NgModule({ bootstrap: [AppComponent], declarations: [AppComponent], imports: [BrowserModule], }) export class AppModule {}// main.ts import { enableProdMode } from '@angular/core'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { AppModule } from './app/app.module'; import { environment } from './environments/environment'; if (environment.production) { enableProdMode(); } platformBrowserDynamic().bootstrapModule(AppModule);How a pre-Ivy Angular application gets bootstrapped.
With the advent of the Angular Ivy renderer, this old approach may no longer be necessary.
// app.component.ts import { Component, OnInit } from '@angular/core'; @Component({ selector: 'ivy-app', template: ` <h1>Hello, {{ name }}!</h1> `, }) export class AppComponent implements OnInit { name: string = 'World'; ngOnInit(): void { this.name = 'Ivy'; } }// main.ts import '@angular/compiler'; import { Injector, Sanitizer, ɵLifecycleHooksFeature as LifecycleHooksFeature, ɵrenderComponent as renderComponent } from '@angular/core'; import { DomSanitizer, ɵDomSanitizerImpl as DomSanitizerImpl } from '@angular/platform-browser'; import { AppComponent } from './app.component'; const rootInjector: Injector = Injector.create({ name: 'root', providers: [ { deps: [], provide: DomSanitizer, useClass: DomSanitizerImpl, }, { provide: Sanitizer, useExisting: DomSanitizer, }, ], }); renderComponent(AppComponent, { hostFeatures: [LifecycleHooksFeature], injector: rootInjector, sanitizer: rootInjector.get(Sanitizer), });Bootstrapping a component with Angular Ivy.
All we have to do is hand our root component over to
renderComponentinside the main file. By default,renderComponentchooses the browser platform by relying on a DOM renderer.Ivy also eliminates the need for the
enableProdModefunction. In its place, a global object variable namedngDevModeis used. In the future, tooling should handle setting this variable automatically.To get dependency injection up and running, a root injector needs to be created.
If our
AppComponentmakes use of lifecycle hooks, we must also includeLifeCycleHooksFeaturein thefeaturesarray. It is worth noting that this addition is required only for bootstrapped components that utilize lifecycle hooks; child components should not include this feature.For sanitizing rendered content and HTML attributes, a DOM sanitiser must be added as well.
A departure from what we're used to
Choosing to bootstrap a component with
renderComponentmeans giving up a number of features that have long been standard in Angular applications:
- No
NgZone- No application initialisers
- No application initialisation status
- No application bootstrap hooks
- No automatic change detection triggered by local UI state changes from HTTP requests, timers, event bindings, and similar sources
Instead, we are responsible for notifying Angular when local UI state in component properties changes. This is accomplished by calling the
markDirtyfunction, which is exported from the@angular/corepackage.Standalone bootstrap is a choice
It's crucial to note that bootstrapping a standalone component is an optional rendering approach, as Angular Ivy maintains full backwards compatibility. We will still have the option to bootstrap an Angular module and retain all the features we expect, all without needing to manually track the dirty state of local UI.
Explicit entry components are gone
Angular Ivy removes Angular module factories from our compiled output. Instead, each component carries its own component factory within its metadata. This makes components self-contained, independent, and tree-shakable.
Consequently, we can bootstrap any component onto the DOM. In the same vein, any component can be dynamically rendered to the DOM through a view container, component outlet, template outlet, CDK portal outlet, router outlet, or even directly via
renderComponent.Because every Ivy-compiled component can serve as an entry component, the
entryComponentsoption within theComponentdecorator becomes obsolete for Ivy components.Lazy-loading standalone components
The Angular Router has historically been the go-to method for code-splitting an application. Alternative approaches include the
lazyModulesoption in the Angular CLI configuration file,angular.json, or supplying our own customNgModuleFactoryLoader.In due course, Ivy is expected to enable lazy-loading and rendering of standalone components, likely through the function-like dynamic
importsyntax. The implementation would look something like this:// ivy-lazy-loading.ts import { renderComponent } from '@angular/core'; import('./my-ivy.component').then(({ MyIvyComponent }) => renderComponent(MyIvyComponent, rootInjector));Using Angular Ivy to lazy-load standalone components.
Dependencies that are tree-shakable
Angular modules have long been the standard way to configure injectors for class-based services and other dependencies. Since Angular 6, however, we have the option to supply dependencies without involving any
NgModuledeclarations for many cases.Tree-shakable dependencies bring several benefits: they are simpler to understand, less prone to errors, and, most importantly, they lead to smaller application bundles. This advantage is particularly significant when these dependencies are shared or distributed as part of a published library.
As with many other aspects of Angular, the framework offers considerable flexibility when it comes to managing dependencies. For a deeper dive into this topic, check out the article “Tree-shakable dependencies in Angular projects”.
Where NgModules still shine: dynamic and multi providers
Angular modules retain their relevance in scenarios that call for dynamic provider configuration. The
RouterModuleis a prime illustration, offering static factory methodsforRootandforChildto set up routing-related providers.A further use case involves multi providers. The widely-used
APP_INITIALIZERtoken is a representative example of this pattern in action.Impact on libraries and shared bundles
As detailed in "Tree-shakable dependencies in Angular projects", tree-shakable providers offer significant advantages for Angular libraries and shared bundles. With the Build Optimizer from the Angular Development Kit, which is part of the default production build in the Angular CLI, even unused declarables can be eliminated from the final bundle.
However, the same cannot be said for entry components. These are not subject to tree-shaking. Libraries such as Angular Material contain several of these components. Any component that is displayed via an Angular CDK Overlay or Portal Outlet must be registered as an entry component, which is the case for Angular Material's Autocomplete, Datepicker, and Select.
Had Angular Material consolidated all its declarables into a single module, merely importing the library would pull every one of these entry components into the application bundle, adding unnecessary weight to the final output.
Looking at compilation schemas
Compilation schemas are another area where NgModules currently play a part. The
CUSTOM_ELEMENTS_SCHEMAandNO_ERRORS_SCHEMAare notable examples. It remains uncertain how Ivy will address this for standalone components. Current evidence suggests that Angular will hand off responsibility for custom elements to the browser.The Angular team’s take
In an interview at AngularConnect 2018, Igor Minar was asked what he would remove or change. His response indicated that Angular modules are his top target for removal and that the team is actively striving to make them an optional feature.
I think
NgModuleis something that if we didn’t have to, we wouldn’t introduce. Back in the day, there was a real need for it. With Ivy and other changes to Angular over the years, we are working towards making those optional.
— Igor Minar at AngularConnect 2018Alex Rickabaugh shares this sentiment, pointing out that the complexity of
NgModuleoften trips up those new to the framework.The way
NgModuleworks tends to be confusing to new Angular developers. Even if we weren’t to rip it out completely, we would change how it works and simplify things.
— Alex Rickabaugh at AngularConnect 2018During his ng-conf 2019 presentation, "Not Every App is a SPA", Rob Wormald introduced a proposal for a
depsmetadata option targeted at component and element decorators. He invited community members to try it out and share their experiences with him.Closing thoughts
Historically, Angular modules served as crucial compile-time artifacts, orchestrating injector configurations and resolving declarables for components. The module's scope was essential for this resolution since templates reference declarables only through their selectors and pipe names.
This additional layer of abstraction has, unfortunately, proven challenging for many to grasp, especially those new to Angular. However, with recent changes to injection token APIs and the impending Ivy rewrite, the framework is moving toward a future where
NgModules are no longer a requirement for building applications and libraries.The transition doesn't have to wait. We can already decouple injector configuration from Angular modules today. Additionally, adopting Single Component Angular Modules allows us to start preparing for the shift to standalone components.
Even when Ivy permits the declaration of
depsdirectly in standalone components, we will still need to manually maintain these lists against the declarables used in templates. In the future, Angular tooling may be able to handle this synchronization automatically.Finally, rest assured that
NgModules are here to stay for the foreseeable future. They remain a valid option, but as the techniques in this article demonstrate, they will frequently be optional.See you in a less NgModular future! 🚀
Additional resources
Slides for my talk Angular revisited: Tree-shakable components and optional NgModules:
This presentation covers extra methods for eliminating some Angular modules at present, leveraging experimental Ivy APIs for change detection and rendering.
The following is a video of my talk from ngVikings 2019 in Copenhagen:
Further reading
A detailed, step-by-step guide on refactoring a basic app from a monolithic module with all declarables into SCAMs can be found in Emulating standalone components using single component Angular modules.
Dependency injection is fundamental to Angular. Since version 6, the framework has supported tree-shakable dependencies that are simpler to reason about and result in leaner bundles. For a comprehensive breakdown, refer to Tree-shakable dependencies in Angular projects.
A valuable pattern often benefits from a schematic. Younes has created a SCAM schematic, which he discusses along with some insights on the pattern in his article “Your Angular Module is a SCAM!”.
Acknowledgements
A heartfelt thank you goes out to all the Angular experts who provided invaluable feedback on this piece and offered deep technical knowledge about Angular Ivy 🙇♂️
I continue to meet wonderful, helpful people like these in various Angular communities.
Angular Revisited: Standalone components and optional NgModules
NgModule is arguably one of the most confusing Angular concepts.


