Organising your stuff feels good! Cover photo by Bynder on Unsplash.
Original publication date: 2019-06-21.
SCAMs (single component Angular modules) offer a way to mimic standalone components. The idea is straightforward: each Angular module takes on the single responsibility of declaring and exporting exactly one component. By importing other SCAMs alongside narrowly scoped third-party Angular modules, a SCAM tells the compiler precisely which declarable dependencies — the components, directives, and pipes appearing in its template — need to be linked to that component.
Our demonstration app is intentionally small. It features the zippy component, a button directive equipped with a bespoke click handler, and the capitalize pipe. The refactoring journey from the current setup to a SCAM-based architecture prepares this application for a future where standalone components become a reality.
It's worth noting that true standalone components remain on the horizon; they require the introduction of a
depsmetadata option for components, a proposal that is yet to materialize.The starting point is a conventional View Engine application where the root module is the sole registry for every declarable item. This is typical for simpler applications—having one central module and letting the framework wire up all the internal links between components and their template dependencies is an approach many developers gravitate towards for its simplicity.
The codebase we work from is hosted on GitHub at ngx-zippy-view-engine. If you prefer to experiment within the browser, you can access the corresponding StackBlitz workspace.
Restricting the Root Module to its Root Component
At the outset, the root module declares all the components, directives, and pipes in the application. Our goal is to distribute these declarables, giving each one its own dedicated Angular module.
// 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 {}View Engine: Everything declared in
AppModule.Because a particular component, directive, or pipe can only have one declaring Angular module, we begin the transition by stripping away all entries in the root module's declarations array, leaving only the root component itself.
// 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 {}SCAMs: All declarations except the root component are removed.
If the child components required imports for any widget libraries—like Angular Material modules—those would be cleared out at this stage as well.
Building a SCAM Around the Zippy Component
We can now turn our attention to constructing a SCAM for the zippy component.
<!-- zippy.component.html --> <button appButton (appClick)="onToggle()"> {{label}} </button> <div [hidden]="!isExpanded"> <ng-content></ng-content> </div>// 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 {}Figure 1. The zippy component and its SCAM.
The
ZippyModuletakes on the tasks of declaring and exporting the zippy component. This module sits at the center of the zippy component's universe.
The transitive compilation scope of the zippy SCAM.
Not every SCAM is responsible for exporting its component. When a component is the target of routing or is bootstrapped, its SCAM plays a different role.
In the case of a routed component, the SCAM neither exports the component nor does it take on the responsibility of configuring the route itself.
For a bootstrapped component, the SCAM refrains from exporting it and also omits bootstrapping details—that responsibility falls to a designated root Angular module.
There is also a distinct pattern for dynamic components: their SCAMs register them as an entry point in the
entryComponentsarray rather than listing them in exports. (This requirement only applies to the View Engine.)
The SCAM for a dynamic component omits the export but includes the component in
entryComponents(a View Engine restriction), signaling to the compiler that this component must always be included in the production bundle.Generating a SCAM for the Button Directive
Upon inspecting the zippy component's template, we find a reference to the button directive. This directive counts as a declarable dependency for the zippy component, so we must import a module that exports it.
Returning to Mark 1 of Figure 1 will show that we anticipated this need by importing
ButtonModule. Our next step is to bring that SCAM into existence.// 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 {}The button directive and its SCAM.
As it turns out, the SCAM methodology is not limited to components; directives and pipes can also be wrapped in their own modules. The term "single component Angular module" is admittedly a bit of a misnomer in these cases, but favoring one consistent pattern and name is more manageable.
Because directives and pipes don't have templates of their own, their SCAMs have a simpler structure. They have no need for module imports to fetch other declarables, and the concept of an entry component is irrelevant to them. Each such SCAM is built with a lone declaration and a single corresponding export.
The mechanics of the
ButtonModuleare minimalistic: it both declares and exports theButtonDirective. That's the whole of it.With its dependencies now imported, the zippy component is functional. Let's shift our attention back up to the root
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 {}The root module now imports the zippy SCAM.
The
ZippyModulehas now been added to the imports array of the root module. We'll inspect the root component's template to identify any remaining outstanding 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'; }The root component model and template.
The root component's template references only one other component: the zippy component. Inside its projected content, the code interpolates the
titleproperty, passing it through thecapitalizepipe for transformation.Building a SCAM for the capitalize pipe
The capitalize pipe qualifies as another declarable dependency. In the same fashion as before, a dedicated SCAM can be constructed for it.
// 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 {}The SCAM that wraps the capitalize pipe.
The structure mirrors what was done for the directive: the module both declares and re-exports the pipe, with nothing else in between. Any component intending to use
CapitalizePipemust pull in this SCAM as an import.Next, the
AppModuleshould be updated to include this new module.// 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 {}The application's root module now references the capitalize SCAM.
With that final change, the refactoring is complete. What previously lived as a set of declarations inside a single root module has been reorganized into individual Angular modules, each responsible for exactly one declarable.
The view of the root module's transitive compilation scope.
The resulting codebase is available in the ngx-zippy-scams repository, and a runnable instance is hosted in this StackBlitz project.
The original app's transitive compilation scope
When every declaration lives in one module, a lot of coupling is implicit. Consider the pieces involved: the zippy component is dependent on the button directive, while the root component relies on both the zippy component and the capitalize pipe.
Although these elements form a nested component hierarchy, they are all registered under the very same Angular module.
For a template to be compiled, Angular must resolve selectors — for components, directives, and pipes — to their corresponding implementations. This resolution takes place through the module that declares the component. That module's transitive compilation scope provides the complete mapping.
In the initial version of the application,
ZippyComponentandButtonDirectivewere both declared byAppModule. This means the directive is found within the scope, so the zippy template compiles without issue.Likewise,
AppComponentdepends onZippyComponentandCapitalizePipe, both of which are also declared insideAppModule. The dependencies resolve correctly, and the root component's template is valid for compilation.Put simply, keeping every declarable in one module means they all inherit an identical transitive compilation scope.
Transitive compilation scopes in the SCAM-based application
With the SCAM approach used in the refactored zippy app, each component's transitive compilation scope aligns perfectly with its template's declarable needs — nothing more is added, nothing necessary is omitted.
The transitive exported scope of a SCAM typically covers just that one declarable. Modules wrapping routed symbols or bootstrapped components have an empty exported scope. A SCAM tied to a dynamically instantiated component also has none, though it does reference the component through entry component configuration, as mentioned earlier.
Standalone components
Photo by Providence Doucet on Unsplash.
The drive to adopt SCAMs stems largely from a desire to approach standalone components with localized component scopes. Under Ivy, every component is treated as an entry point by default — it can be rendered dynamically, associated with a route, or used to bootstrap the application.
With View Engine, the rendering engine currently in place, the notion of an entry component prevents tree shaking from dropping these symbols. The metadata explicitly tells the compiler to retain the component unconditionally, even if no other template references it.
A proposal suggesting a
depsmetadata field for component and element decorators would fully realize standalone capabilities. Every declarable used in a template would be explicitly listed within its component's metadata. If a declarable instance goes unused, it disappears from anydepsentries and consequently from allimportstatements, making it eligible for removal during bundling.For quite some time, the Angular Devkit Build Optimizer has handled removal of declarables that aren't referenced in component templates. This applies regardless of whether they appear in the exported scope of an imported module or in any module declared within the application itself. The noted exception remains entry components, which the optimizer cannot strip from the final bundle.
Improved testability through SCAMs
Photo by Louis Reed on Unsplash.
Because a SCAM imports only the declarables needed to render one specific component template, setting up tests becomes noticeably simpler. There is no need to overload the testing module with unrelated dependencies or resort to shallow rendering techniques.
Simplified detection of unnecessary imports
Photo by Alexander Csontala on Unsplash.
In a module that declares a large assortment of components, determining which imports are actually needed is tedious. The audit demands going through the template of every single component that the module declares.
As touched on in the standalone section, the build optimizer can eliminate unused declarables except for entry components. Another limitation is that dependencies coming from a module's
providersmetadata cannot be shaken. So importing extra modules may inflate the bundle shape even with the optimizer active.With SCAMs, the verification workload shrinks to examining just one template. The imported modules are valid and necessary only if each declarable in the template maps to its corresponding SCAM elsewhere, or potentially a third-party module.
Component-level code splitting
Photo by Tim Krauss on Unsplash.
Restricting an Angular module to a single component opens the door to split code along component boundaries. This is achievable via lazy loaded routes, the
"lazyModule"configuration flag set inangular.json, and dynamicimport()expressions. Alternatively, a component may be packaged as its own library and then lazily retrieved through a dynamicimport().The elephant in the room
Photo by Daniel Brubaker on Unsplash.
SCAMs offer several advantages, as we covered earlier, but they come with a trade-off. Because every component, directive, and pipe gets its own Angular module, the total module count in an application rises considerably.
If you have followed my effort to eliminate Angular modules entirely, you will recognize SCAMs as an intermediate step. They provide a safe migration path that works with View Engine today while paving the way toward standalone components. I cannot guarantee that the component API under discussion will land in Angular, but sharing our experiences with the Angular team can help shape whether it does.
Summary
A SCAM is an Angular module limited to a single declarable. For directives and pipes, that means declaring and exporting exactly one item.
Each SCAM declares its component and imports an Angular module for every declarable dependency that appears in the component template. Most components are exported by their SCAM, with two exceptions: routed components and bootstrapped components. Dynamically rendered components are not exported either, but they must be registered as entry components.
Standalone components remain the ultimate objective. This becomes feasible if the
depsoption survives the proposal phase. With standalone components, any component actually used in the application gets referenced; an unused component is referenced by nobody.Through Ivy, every component can be dynamically rendered or bootstrapped when experimental APIs are employed. Upcoming articles will dig into these Ivy APIs, showing how to build compilation scopes with fewer Angular modules and references.
Resources
The original zippy example:
The zippy example rebuilt with SCAMs:
Slides for my session “Angular revisited: Tree-shakable components and optional NgModules”:
That talk walks through further strategies for reducing the number of Angular modules today, relying on experimental Ivy APIs for change detection and rendering.
Below is the video of the same talk, recorded at ngVikings 2019 in Copenhagen:
Related articles
Standalone components are just one approach to making Angular modules optional. To see what the Ivy era may bring, check out “Angular revisited: Tree-shakable components and optional NgModules”.
Peer reviewers
This article is better thanks to the following people, who generously lent their expertise. Thank you all! 🙇♂️
Emulating standalone components using single component Angular modules (SCAMs)
SCAMs are a safe, View Engine-compatible migration path towards standalone components.










