DEV Community

Cover image for Emulating standalone components using single component Angular modules (SCAMs)
Lars Gyrup Brink Nielsen for This is Angular

Posted on • Edited on

Emulating standalone components using single component Angular modules (SCAMs)

Organising your stuff feels good! Cover photo by Bynder on Unsplash.

Original publication date: 2019-06-21.

SCAMs (single component Angular modules) can emulate standalone components by having an Angular module only be concerned about declaring and exporting a single component. A SCAM instructs the Angular compiler to link declarable dependencies (components, directives, and pipes used in a component template) to its component template by importing other SCAMs and fine-grained third-party Angular modules.

We’ll work with a small application containing the zippy component, a button directive with a custom click handler, and the capitalize pipe. To prepare the application for standalone components, we will refactor the application to use SCAMs.

Please note that truly standalone components will only be possible if Angular adds the deps metadata option for components. A proposal that is at this point only an idea.

We start out with the View Engine application which lists every declarable in its root module. This is how simple applications are often developed, because it’s easy to have a single Angular module and let the Angular framework take care of figuring out the details of linking declarable dependencies to component templates.

The GitHub repository ngx-zippy-view-engine is our starting point. You can follow along using the StackBlitz workspace.

Scoping the root module to the root component

Every component, directive, and pipe is declared in the root module. We want to have an Angular module per declarable.

// app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { ZippyComponent } from './zippy.component';
import { ButtonDirective } from './button.directive';
import { CapitalizePipe } from './capitalize.pipe';

@NgModule({
  bootstrap: [AppComponent],
  declarations: [AppComponent, ButtonDirective, CapitalizePipe, ZippyComponent],
  imports: [BrowserModule],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

View Engine: Everything declared in AppModule.

Components, directives, and pipes can only be declared in a single Angular module. So let’s start by removing all declarations except the root component.

// 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

SCAMs: All declarations except the root component are removed.

If we had widget module imports used by the child components such as Angular Material modules, we would also remove them.

Creating a SCAM for the zippy component

Let’s create a SCAM for the zippy component.

<!-- zippy.component.html -->
<button appButton (appClick)="onToggle()">
  {{label}}
</button>

<div [hidden]="!isExpanded">
  <ng-content></ng-content>
</div>
Enter fullscreen mode Exit fullscreen mode
// zippy.component.ts
import { Component, Input, NgModule } from '@angular/core';

import { ButtonModule } from './button.directive';

@Component({
  selector: 'app-zippy',
  templateUrl: './zippy.component.html',
})
export class ZippyComponent {
  @Input()
  label = 'Toggle';

  isExpanded = false;

  onToggle() {
    this.isExpanded = !this.isExpanded;
  }
}

@NgModule({
  declarations: [ZippyComponent],
  exports: [ZippyComponent],
  imports: [
    ButtonModule, // [1]
  ],
})
export class ZippyModule {}
Enter fullscreen mode Exit fullscreen mode

Figure 1. The zippy component and its SCAM.

The zippy SCAM declares and exports the zippy component.

The transitive compilation scope of the zippy SCAM.

SCAMs for routed components and bootstrapped components do not export their component.

The SCAM for a routed component doesn’t export its component. It also doesn’t configure routes.

The SCAM for a bootstrapped component doesn’t export its component. It also doesn’t add bootstrapping instructions. That is a job for a root Angular module.

SCAMs for dynamic components lists their component as an entry component instead of an exported component (only required in View Engine).

The SCAM for a dynamic component doesn’t export its component but it lists it as an entry component (only required in View Engine). This instructs the compiler to always include it in an application bundle.

Creating a SCAM for the button directive

The zippy component uses a button directive in its template. This button directive is a declarable dependency to the zippy component, so we need to import an Angular module that exports it.

In Mark 1 of Figure 1, we imported the button directive’s SCAM, ButtonModule. Let’s make sure to create this SCAM.

// button.directive.ts
import { Directive, EventEmitter, HostListener, NgModule, Output } from '@angular/core';

@Directive({
  selector: '[appButton]',
})
export class ButtonDirective {
  @Output()
  appClick = new EventEmitter<void>();

  @HostListener('click')
  onClick() {
    console.log('Click');
    this.appClick.emit();
  }
}

@NgModule({
  declarations: [ButtonDirective],
  exports: [ButtonDirective],
})
export class ButtonModule {}
Enter fullscreen mode Exit fullscreen mode

The button directive and its SCAM.

Yes, we can create SCAMs for directives and pipes as well. I know the full name (single component Angular module) doesn’t make as much sense but let’s stick to a single concept and a single name.

Since directives and pipes don’t have templates, their SCAMs don’t have Angular module imports or entry components. Each of them will only ever have a single declaration and a single exported declarable.

The ButtonModule SCAM declares and exports the ButtonDirective. It’s as simple as that.

Now the zippy component has all of its declarable dependencies imported and it should work. Let’s return to the AppComponent.

// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from './app.component';
import { ZippyModule } from './zippy.component';

@NgModule({
  bootstrap: [AppComponent],
  declarations: [AppComponent],
  imports: [BrowserModule, ZippyModule],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

The root module now imports the zippy SCAM.

We’ve added the zippy SCAM to the root module’s imports. Let’s look at the component template to see if we have other declarable dependencies.

// app.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <app-zippy label="Click me">
      {{ title | capitalize }}
    </app-zippy>
  `,
})
export class AppComponent {
  title = 'single component angular modules';
}
Enter fullscreen mode Exit fullscreen mode

The root component model and template.

The only component used by the root component template is the zippy component. In the projected content, we interpolate the title property and pipe it through the capitalize pipe.

Creating a SCAM for the capitalize pipe

The capitalize pipe is another declarable dependency. Let’s create a capitalize SCAM.

// capitalize.pipe.ts
import { NgModule, Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'capitalize',
})
export class CapitalizePipe implements PipeTransform {
  transform(value: string) {
    return value
      .split(/\s+/g)
      .map((word) => word[0].toUpperCase() + word.substring(1))
      .join(' ');
  }
}

@NgModule({
  declarations: [CapitalizePipe],
  exports: [CapitalizePipe],
})
export class CapitalizeModule {}
Enter fullscreen mode Exit fullscreen mode

The capitalize pipe and its SCAM.

Similar to a directive’s SCAM, a pipe’s SCAM only declares and exports a pipe. The CapitalizeModule declares and exports the CapitalizePipe. A component that uses this pipe must import its SCAM.

Let’s go back to the AppModule and add the capitalize SCAM.

// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from './app.component';
import { CapitalizeModule } from './capitalize.pipe';
import { ZippyModule } from './zippy.component';

@NgModule({
  bootstrap: [AppComponent],
  declarations: [AppComponent],
  imports: [BrowserModule, CapitalizeModule, ZippyModule],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

The root module now imports the capitalize SCAM.

Great, the application is now refactored from having all declarations in the root module to having a single Angular module per declarable.

The transitive compilation scope of the root Angular module.

The GitHub repository ngx-zippy-scams contains the resulting application. You can also see a live version in this StackBlitz workspace.

Transitive compilation scope in the original application

You know, having all declarations in the same Angular module is pretty mind-boggling. The zippy component uses the button directive. The root component uses the zippy component and the capitalize pipe.

So we have a nested component tree, but all components are declared by the same Angular module.

For a component template to work, Angular needs to know how to map component selectors to components, directive selectors to directives and pipe names to pipes. Angular will consider the Angular module declaring the component. The declaring Angular module has a transitive compilation scope which lists these mappings to declarables.

The ZippyComponent uses the ButtonDirective. They were both declared by the AppModule in the original application. The declarable dependency is linked and the component template can be compiled.

The AppComponent uses the ZippyComponent and the CapitalizePipe. They were all declared by the AppModule in the original application. The declarable dependencies are linked and the component template can be compiled.

In short, when all declarations are in the same Angular module, they all share the same transitive compilation scope.

The transitive compilation scopes in a SCAM application

In an application using SCAMs such as our refactored zippy application, all components have a transitive compilation scope that matches exactly the declarable dependencies used by their templates. Nothing more, nothing less.

A SCAM’s transitive exported scope consists at most of its specific declarable. A routed or bootstrapped component’s SCAMs will have an empty transitive exported scope. This is also the case for a dynamic component’s SCAM, but it will instead list the component as an entry component liked mentioned in a previous section.

Standalone components

Photo by Providence Doucet on Unsplash.

One of the main reasons for using SCAMs is to end up with standalone components having local component scopes. With the Ivy rewrite, every component is treated as an entry component, meaning that it can be dynamically rendered, it can be routed and it can be bootstrapped.

In the View Engine—the current Angular rendering engine—entry components cannot be tree shaked from our application bundles. Their entry component metadata annotations are there to explicitly instruct the compiler to always include them, even though they might not be used in any component templates.

The proposal for adding a deps metadata option to Angular component and element decorator factories represents true standalone components since every declarable dependency must be directly referenced in the metadata of the component using it. So if a declarable is unused, it will not be referenced in the deps option of any component or element and therefore not listed in any import statement. Because of this, it can be tree shaked away by our build process.

For the longest time, the Angular Devkit Build Optimizer has been able to tree-shake away declarables that are not mentioned in component templates, even if they are in the exported compilation scope of Angular modules that we import or if they are declared in Angular modules in our application. The exception is components that are explicitly listed as entry components. These components can’t be left out of our application bundles by the build optimizer.

Testing components is easier with SCAMs

Photo by Louis Reed on Unsplash.

SCAMs import exactly the declarables needed to render a single component template. This makes them useful for component testing, since we won’t have to configure as many options in the testing module or use shallow rendering.

Identifying unused imports is easier with SCAMs

Photo by Alexander Csontala on Unsplash.

In an Angular module with many declarations, how will we identify imports that are unused? We would have to go through the component template of every component declared by the Angular module.

As I mentioned in the section Standalone components, the build optimizer will shake unused declarables from our bundles, but they are unable to exclude entry components. On a side note, we also cannot tree shake dependencies listed in the providers metadata of an Angular module that we import. So too many imported Angular modules might increase our application bundle despite using the build optimizer.

Using SCAMs, we only have to consider a single component template to check whether we have unused imported Angular modules. For every component, directive, and pipe used in the component template, its SCAM imports another SCAM or a third-party Angular module.

Code-splitting on the component level

Photo by Tim Krauss on Unsplash.

When we scope an Angular module to a single component, we can split our code on the component level. We can do so using lazy loaded routes, the "lazyModule" option in angular.json and dynamic import()s. Alternatively, we can compile a component as a separate library and lazy load it, again by using a dynamic import().

The elephant in the room

Photo by Daniel Brubaker on Unsplash.

As mentioned in the previous section, SCAMs bring some benefits to the table but not everything is golden. SCAMs mean more Angular modules since we will have one for every component, directive, and pipe in our application.

You might be aware that I’m on a mission to get rid of all Angular modules. SCAMs are a means to an end. They are a safe, View Engine-compatible migration path towards standalone components. While I can give you no promise that the proposed component API will become part of Angular, we should all give our feedback to the Angular team about whether this is useful to our applications and use cases.

Summary

SCAMs (single component Angular modules) are Angular modules that are scoped to a single declarable. For directives and pipes, they declare and export a single declarable.

A SCAM will declare its component. It will import an Angular module for every declarable dependency in the component template. Most components will be exported by their SCAM. However, routed and bootstrapped components will not be exported. Dynamically rendered components will not be exported by their SCAM, but they will be listed as an entry component.

The end goal is to have standalone components. That will become possible if the deps option makes it past the proposal stage. With standalone components, a component that is used in the application will be referenced. If it’s unused, no other component will reference it.

Ivy allows all components to be dynamically rendered or bootstrapped if we use experimental APIs. In future articles, we will explore those experimental Ivy APIs to create compilation scopes with less Angular modules and references.

Resources

The initial zippy application:

The zippy application refactored using SCAMs:

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

Standalone components is just one of the techniques used to make Angular modules optional. Read what you can expect from the upcoming Angular Ivy era in “Angular revisited: Tree-shakable components and optional NgModules”.

Peer reviewers

I had help shaping up this article for your enjoyment. Thank you, dear reviewers 🙇‍♂️ Your help is much appreciated.

Top comments (11)

Collapse
 
wojtek1150 profile image
Wojciech

What bout bundle sizes? Same/smaller/bigger? For small apps I belive that there will be no changes, have you any real-app example of comparing before-after state?

Collapse
 
layzee profile image
Lars Gyrup Brink Nielsen

Individual Angular modules pretty much don't exist at runtime so no difference at all. They are compiled into static properties such as module injectors and transitive module scopes. If anything, big Angular modules declaring many declarables would result in larger bundles if it wasn't for the Angular Build Optimizer.

SCAMs can help guide the Angular compilers to split bundles more effectively.

If you're interested in before and after, try SAM4SC.

Collapse
 
haydenbr profile image
Hayden Braxton

This doesn't seem to scale very well with larger applications. I started down this road, but there was just too much repetition to stomach. But maybe I'm missing something?

Collapse
 
layzee profile image
Lars Gyrup Brink Nielsen

I would say it scales very well. We are able to refactor our application more easily because declarables are not tied to an Angular module that sets up declarations and compilation scope for multiple declarables.

What's the repetition you noticed?

On a side note, this was the first step towards components with local compilation scope. There are two more techniques, albeit experimental, that we cam use today. I didn't cover them in article yet because I thought we would get a stable API for this with Angular Ivy. However, that is still in the far end of the roadmap, so feel free to revisit these old techniques youtu.be/DA3efofhpq4

Collapse
 
haydenbr profile image
Hayden Braxton

A large application has a lot of components. For each of those components, I now have to also create an NgModule for it and import any modules that export declarations that I depend on. So most likely, I'm at least importing CommonModule (for ngIf, async, etc) and probably some other ThirdPartyModule if I use third party components. So that's the first line of repetition.

As I move further up the component tree from UI-interaction related components to business related components, I'm importing more ngModules for all the lower level components I need, and likely still CommonModule and ThirdPartyModule. So the result is a lot of import { SomeComponentModule } from './some.component.ts' and a long list NgModule imports.

To quote the article "SCAMs mean more Angular modules since we will have one for every component, directive, and pipe in our application." This creates a lot of extra boiler code.

I can understand the value of refactoring: I can move the component anywhere and it already has all of the dependencies it needs, but I would also question how often that is a need.

Clarification question: Suppose I have some kind of compound component where I always use a combination of components in concert. Like, maybe I have something like:

<my-accordion>
  <my-accordion-section>
    <my-accordion-header>Section 1</my-accordion-header>
    <my-accordion-content>Some content</my-accordion-content>
  </my-accordion-section>
  <my-accordion-section>
    <my-accordion-header>Section 2</my-accordion-header>
    <my-accordion-content>Some more content</my-accordion-content>
  </my-accordion-section>
</my-accordion>
Enter fullscreen mode Exit fullscreen mode

If I wanted to follow the SCAM approach, there would be one module for each of these four components. So then anyone who wanted to use an accordion would have to import all 4 modules. Or would it be acceptable to create some parent AccordionModule that has no declarations and simply export all 4 component modules so that anyone who needs an accordion simply imports this single parent module?

Thread Thread
 
snowfrogdev profile image
Philippe Vaillancourt

The way I understand the SCAM approach and have been using it is not that you should literally have a module for every single component in your app. You should have one module for every single component that is reusable in isolation.

So, for instance, if I have a lazy-loaded OrderListModule that has a parent OrderListComponent and that parent has a child OrderDetailsComponent that is not reused anywhere in the app, I would just declare OrderDetailsComponent in the OrderListModule.

If I have a set of reusable components that are only meant to be used together, like in your example, I would also declare all of those in one module. If you look at the Angular Material project, that is how they do it.

Thread Thread
 
layzee profile image
Lars Gyrup Brink Nielsen

That would be a feature module.

Collapse
 
maximaximum profile image
Maksym Kobieliev

Heads up! There's an Angular issue open that builds on the idea of optional NgModules and suggests to make the declarable's selectors overridable and configurable, so that people can use them just the way we are currently using providers.

If you'd like to be able to use optional NgModules and the extended capabilities, 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. 20 days left from now!

Collapse
 
weirdyang profile image
weirdyang

How do you share services?

Collapse
 
layzee profile image
Lars Gyrup Brink Nielsen

Usually using tree-shakable providers. Or just importing them and providing them at the component level depending on the use case.

Collapse
 
weirdyang profile image
weirdyang

Thanks!