DEV Community

Cover image for Angular Revisited: Standalone components and optional NgModules
Lars Gyrup Brink Nielsen for This is Angular

Posted on • Updated on

Angular Revisited: Standalone components and optional NgModules

It’s time to revisit our Angular engine room. Cover photo by Gregory Butler on Pixabay

Original publication date: 2019-02-11.

NgModule is arguably one of the most confusing Angular concepts.

Fortunately, Angular is moving towards a future in which we need Angular modules (NgModules) less often or not at all.

In a future version of Angular, Ivy will enable us to bootstrap or render components to the DOM without any Angular modules. We will also be able to use other components, directives, and pipes in our component templates without Angular modules to resolve them.

These features are not yet available, but we can start preparing now to ease the migration path.

Standalone components

In the current Angular generation, a component can and must only be declared in a single NgModule. The declarables (components, directives, and pipes) that can be used by that component is determined at compile time from the metadata of its declaring Angular module.

Transitive module scope

Every Angular module has a transitive module scope which is decided at compile time. The transitive module scope actually consists of two scopes: A transitive compilation scope and a transitive exported scope.

Transitive exported scope

The transitive exported scope is all the declarables that an Angular module lists in the exports option of its metadata. It can also re-export other Angular modules by listing them in that same option. Their exported scopes will then become part of this Angular module’s transitive exported scope.

Transitive compilation scope

The transitive compilation scope of an Angular module consists of all the declarables that a component declared by that Angular module can use in its template.

The components, directives, and pipes are combined with the transitive exported scope of the Angular modules that are listed in the imports option of this Angular module’s metadata.

The transitive hero module compilation scope

Let’s look at 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) {}
}
Enter fullscreen mode Exit fullscreen mode
// 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;
}
Enter fullscreen mode Exit fullscreen mode
// 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 {}
Enter fullscreen mode Exit fullscreen mode

The hero and hero list components declared by the same Angular module.

The HeroModule declares two components. They both share a transitive compilation scope.


Figure 1. The transitive compilation scope of the hero module.

Figure 1 illustrates the transitive compilation scope of HeroModule. The scope—highlighted in red—includes HeroComponent, HeroListComponent and all the declarables that are exported by CommonModule.

HeroListComponent is compiled in the context of HeroModule‘s transitive compilation scope. This is why it can render HeroComponents using <app-hero> tags. They are both declared in HeroModule.

The hero list component also uses AsyncPipe in its template by using its pipe name, async. This is an example of a declarable that is available since HeroModule imports CommonModule.

Likewise, the NgForOf structural directive is used in the hero list component template even though it’s declared by CommonModule. In the same way, the hero component uses the NgIf structural directive to conditionally render content in its template.

Local component scope

The Angular Ivy rewrite might introduce component-level scope for declarable dependencies. This is called local component scope.

Angular previously had local component scope for declarables. The latest version to support it was Angular 2 RC5. Back then, the Component decorator factory supported the options directives and pipes.

Angular Pull Request #27841 and Angular Ivy Proof of Concept Demo by Angular team member Minko Gechev suggests that the Component decorator factory could get an option called deps which takes an array or a nested array of declarables used in the component template. The final implementation and API may differ.


Figure 2. The local component scopes of the hero list component and the hero component.

Figure 2 illustrates the local component scopes of the hero list component and the hero component if they were converted to standalone components with the syntax of Pull Request #27841. A hero Angular module wouldn’t be necessary. Neither would the CommonModule.

With Angular Ivy, we will be able to render a component independently from an NgModule. Ivy will even enable us to lazy-load and render a component without an Angular module or the Angular Router.

Single component Angular modules

We can’t create standalone components with local component scope yet, but we can start preparing for the future with SCAMs (single component Angular modules).

For each component, we create an NgModule that imports only the declarables used by that specific component. Likewise, it only declares and exports 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 {
    // (...)
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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 {}
Enter fullscreen mode Exit fullscreen mode

A SCAM (single component Angular module).

Sure, it is a bit more work but I actually started doing this in most places anyways. It makes it easier to maintain a current list of declarable dependencies to keep a small bundle size.

When looking at a SCAM, we only have to consider a single component to determine whether an Angular module import is in use.

SCAMs are also useful for testing since they import exactly the declarables needed for the component-under-test.

They could even prove useful for using the Bazel build system. Using one Bazel package per Angular module makes the Bazel build process and setup more seamless.

Converting to standalone components

In the future, we might combine a component and its SCAM into a standalone component by moving the dependencies imported by the NgModule into the deps option of the Component decorator. We would convert the dependencies from Angular module references into references to the actual declarables we use in our 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 {
    // (...)
  }
}
Enter fullscreen mode Exit fullscreen mode

A standalone component.

For example MatButtonModule is converted to MatButton. Currently, the Angular Material components have not been compiled with Angular Ivy and published as standalone components. This will probably be the case for many libraries for a while.

Fortunately, Angular Ivy has taken this into consideration. Ivy comes with 2 compilers. The primary Ivy compiler is executed through the ngtsc process which is a thin wrapper around the TypeScript compiler that transforms Angular decorators to metadata stored in static class properties.

The second compiler that comes with Ivy is called ngcc which is short for the Angular Compatibility Compiler. We can convert pre-Ivy libraries in node_modules by running ivy-ngcc as an NPM script or using NPX (npx ivy-ngcc). It makes sense to do this in the postinstall NPM hook.

Testing standalone components

When integration testing our Angular components today with the Angular testing utilities, we can replace view child components by adding fake components with the same selectors to the Angular testing module.

With standalone components, the view child components that are used are declared in the Component decorator. The Angular team would have to add an API for replacing local scope declarables during tests.

Bootstrapping a standalone component

Since Angular version 2, bootstrapping an Angular application has required us to create an Angular module with a bootstrap option pointing to a root component— by convention the AppModule and AppComponent, respectively.

We are also used to initialising a platform and bootstrapping the AppModule in our main 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';
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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 {}
Enter fullscreen mode Exit fullscreen mode
// 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);
Enter fullscreen mode Exit fullscreen mode

Bootstrapping a pre-Ivy Angular application.

With the Angular Ivy renderer, this approach could become a thing of the past.

// 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';
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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),
});
Enter fullscreen mode Exit fullscreen mode

Bootstrapping an Angular Ivy component.

We simply pass our root component to renderComponent in the main file. By default, renderComponent assumes the browser platform by using a DOM renderer.

Ivy even gets rid of the enableProdMode function. Instead, a global object variable called ngDevMode is used. Future tooling should enable automatic configuration of this variable.

To make dependency injection work, we create a root injector.

If we use lifecyle hooks in AppComponent, we’ll also have to add LifeCycleHooksFeature to the features option array. Note that this is only necessary for bootstrapped components with lifecycle hooks. Child components shouldn’t add this feature.

To sanitise rendered content and HTML attributes, we add a DOM sanitiser.

Not what we have come to expect

If we choose to bootstrap a component with renderComponent, we opt out of a lot of the features we have come to expect from an Angular application:

  • No NgZone
  • No application initialisers
  • No application initialisation status
  • No application bootstrap hooks
  • No automatic change detection on local UI state changes based on HTTP requests, timers, event bindings, and so on

Instead, we have to tell Angular that we changed the local UI state in component properties by using the markDirty function from the @angular/core package.

Bootstrapping a standalone component is optional

It’s important to mention that bootstrapping a standalone component is an opt-in rendering mechanism since Angular Ivy will be fully backwards-compatible. We’ll still be able to bootstrap an Angular module and get all the features we are used to without having to manage the dirtiness of local UI state ourselves.

No more explicit entry components

Angular Ivy rids our compiled code of Angular module factories. Our components contain their component factory in their metadata. They are self-contained, independent and tree-shakable.

This means that we can bootstrap any component to the DOM. Likewise, we can dynamically render any component to the DOM through a view container, component outlet, template outlet, CDK portal outlet, router outlet, or simply renderComponent.

Since every component compiled with Ivy can be used as an entry component, the entryComponents option for the Component decorator will become redundant for Angular Ivy components.

Lazy-loading standalone components

The Angular Router has been the primary way of code-splitting our applications. There are other ways like the lazyModules option of the Angular CLI configuration file, angular.json, or even providing our own NgModuleFactoryLoader.

At some point, Ivy will allow us to lazy-load and render standalone components. Probably using the function-like dynamic import keyword. It will look something like:

// ivy-lazy-loading.ts
import { renderComponent } from '@angular/core';

import('./my-ivy.component').then(({ MyIvyComponent }) => renderComponent(MyIvyComponent, rootInjector));
Enter fullscreen mode Exit fullscreen mode

Lazy-loading standalone components with Angular Ivy.

Tree-shakable dependencies

Angular modules were traditionally the primary method to configure injectors for class-based services and other dependencies. Since Angular version 6, we can often provide dependencies without any NgModules involved.

Tree-shakable dependencies are easier to reason about and less error-prone. Even better, they result in smaller application bundles, especially if used in shared dependencies and published libraries.

Like in most other aspects, Angular is very flexible when it comes to dependencies. Learn all about them in “Tree-shakable dependencies in Angular projects”.

Still useful for dynamic providers and multi providers

One place where Angular modules are still useful is to create dynamic providers such as configuration values. This is exactly what the RouterModule does with its static methods forRoot and forChild.

Another example where Angular modules still have their use is for multi providers. Angular’s APP_INITIALIZER is a common example of this.

Libraries and shared bundles

Like discussed in “Tree-shakable dependencies in Angular projects”, Angular libraries and shared bundles can put tree-shakable providers to good use. With Angular Development Kit’s Build Optimizer which is included in the default Angular CLI production configuration, even unused declarables are tree-shakable.

However, entry components aren’t tree-shakable. A library like Angular Material actually has a few entry components. Every component that is rendered through an Angular CDK Overlay or Portal Outlet needs to be declared as an entry component. This includes Angular Material’s Autocomplete, Datepicker, and Select components.

If Angular Material had only split its declarables into a single Angular module, we would add all its entry components to our application bundle as soon as we imported the library.

Compilation schemas

One final concern that Angular modules currently solve is compilation schemas. Examples are CUSTOM_ELEMENTS_SCHEMA and NO_ERRORS_SCHEMA. How Ivy handles this with regards to standalone components is unknown to me. So far indications show that it’ll defer to the browser to handle custom elements unknown to Angular.

What the Angular team thinks

When asked at AngularConnect 2018 what he would like to rip out or do differently in Angular, Igor Minar replied that at the top of his list of things to remove are Angular modules and that the Angular team is working towards making them optional.

I think NgModule is 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 2018

Alex Rickabaugh agrees and adds that it’s often confusing to new Angular developers.

The way NgModule works 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 2018

In his ng-conf 2019 talk, “Not Every App is a SPA”, Rob Wormald discusses the proposal for a deps metadata option for the component and element decorator factories. Rob asks the community for feedback on this idea so please experiment and get in touch with him to share your findings.

Summary

Angular modules have historically been compile-time software artifacts necessary for configuring injectors and resolving declarables for components. The scope provided by an Angular module was necessary to resolve declarables, since we only refer to them by their selectors and pipe names in our component templates.

Unfortunately, this layer of indirection has proven difficult to learn and reason about, especially for newcomers to the Angular ecosystem. With recent API changes for injection tokens and the upcoming Angular Ivy rewrite, we’ll soon be able to build applications and libraries without NgModules.

We don’t even have to wait. We can already break free from Angular modules for configuring injectors. We can also start preparing for standalone components with Single Component Angular Modules.

Even if Ivy enables us to declare deps in our standalone components, we’ll unfortunately still have to keep these lists in sync with the declarables used in our component templates. Maybe Angular tooling can automate this at a later time.

Lastly, it’s important to mention that NgModules are not going away anytime soon. You can still use them but as seen by the techniques in this article, they will in many cases become optional.

See you in a less NgModular future! 🚀

Resources

Slides for my talk Angular revisited: Tree-shakable components and optional NgModules:

In this talk, I introduce additional techniques for getting rid of some of our Angular modules today, using experimental Ivy APIs for change detection and rendering.

Here’s the recording of my talk presented at ngVikings 2019 in Copenhagen:

Related articles

Step-by-step refactoring of a simple application from a single Angular module containing all declarables to SCAMs in Emulating standalone components using single component Angular modules.

Dependency injection is a key feature of Angular. Since Angular version 6, we can configure tree-shakable dependencies which are easier to reason about and compile to smaller bundles. Learn all the details in Tree-shakable dependencies in Angular projects.

A useful Angular pattern needs a schematic. Younes created a SCAM schematic which he introduces along with some thoughts on the pattern in his article “Your Angular Module is a SCAM!”.

Peer reviewers

An enormous thank you to all of my fellow Angular professionals who gave me valuable feedback on this article and provided deep technical insight on Angular Ivy 🙇‍♂️

I meet wonderful, helpful people like these in various Angular communities.

Top comments (5)

Collapse
 
thorstenhirsch profile image
Thorsten Hirsch • Edited

Hi Lars. Good to have you here! As you write in the introduction it's been 9 months and 3 major Angular releases with Ivy since you wrote the article. Can you give us an updated view of what happened to NgModules in the meantime?

With the Angular Ivy renderer, this approach could become a thing of the past.

From what I see nothing has changed in Angular 11. If both approaches are possible now I wonder how big the overhead of NgModules is... and does it mean we should only use NgModules when we want to use lazy loading?

edit: Ooops, it's been not only 9 months, but 1 year and 9 months.

Collapse
 
layzee profile image
Lars Gyrup Brink Nielsen • Edited

Thanks for your comment, Thorsten

Indeed, I remember starting this research 23 months ago 😊 Back then I was tired of waiting around for Ivy. Lots of talk about optional Angular modules in 2018, but all talk no show. I decided to try to figure out what that would look like in practice by approaching something similar to the deps component metadata proposal.

This year, the Angular team released their first public Angular roadmap which happens to mention optional NgModules. However, note that this item is in the Future section which means that:

  1. It's not actively being worked on.
  2. It might never make it into the Angular framework.

While writing this article, I ended up coining the term SCAM (Single Component Angular Module). I took it two steps further and turned it into a conference talk which I believe is mentioned in this article Part 2 in this series discusses SCAMs further in practice. I started working on Part 3 and planned a Part 4 based on the patterns I produced as part of my research for the mentioned conference talk, but Ivy seemed so close that it didn't make sense.

Seeing that it's now 2 years later and we're still stuck with Angular modules, I might pick up this series again as it's still highly relevant. I'm looking to continue the series in 2021. Until then, you can have a look at these repositories:

I made them shortly after the first stable release of Angular Ivy, back in February 2020. Feature render modules are the final frontier before actual tree-shakable components. The description of the last repository shows that this has potential:

14.1 KB gzipped (41.8 KB uncompressed).

Zippy Angular app using renderComponent and a feature render module.

1 Angular module, 2 components, 1 directive, 1 pipe.

Besides the point that the renderComponent API is still not public (maybe it will become so as part of the possible future optional NgModules effort), there are still unsolved issues mentioned in this very article. I'm starting to plan for a Part 5 of this series which looks into each of these in detail.

I have quite a few spare time writing projects planned, so timelines and interests indicated in this comment may vary.

Collapse
 
ozbobwa profile image
OzBob

component 'deps' prop seems further away than ever:
"Closing the PR for now; we can reopen it once we land more critical blocking work." github.com/angular/angular/pull/27481 Closed.

Also, what do you think of rollupjs.org/guide/en/ for treeshaking?

Collapse
 
layzee profile image
Lars Gyrup Brink Nielsen • Edited

It's currently in the Angular roadmap under the title "Simplified Angular mental model with optional NgModules". It's in the Future section though which means that:

  1. It's not in active development.
  2. It might not ever make it into Angular.

The deps metadata option might not be the exact API we end up with. However, I tried to emulate it using first SCAMs, then component render modules, and finally feature render modules.

Regarding Rollup, Angular CLI has an experimental flag for using Rollup during build: ng build --experimentalRollupPass as of Angular CLI version 9 (still available in version 11).

Collapse
 
maximaximum profile image
Maksym Kobieliev

Heads up! There's an Angular issue open to build up on the idea of optional NgModules and make the declarable's selectors overridable and configurable, just the way we are now using providers.

If you'd love to be able to use optional NgModules, please feel free to vote for the issue: github.com/angular/angular/issues/...

If the issue gets 20+ thumbups until June 23, 2021, it will be considered by the Angular team for implementation.