Playing With Standalone Components / Optional Modules In Angular 14
In the very early days of modern Angular, you could import a Component and then provide it as a declaration to be used within another Component. Then, Angular switched over to using NgModule
, which became the de facto packaging and configuration container for the last 5-or-6 years. Now, in an effort to provide a more streamlined developer experience, Angular is once again allowing Components to be consumed without an intermediary NgMogule
container. This new-old feature is called "Standalone Components", or "Optional Modules". I haven't written too much Angular lately (been focused heavily on Lucee CFML); so, I thought this would be a good chance to dust off my Angular skills.
View this code in my Plate Weight Calculator project on GitHub.
This post is not attempting to be a tutorial on using Standalone Components in Angular. The Angular docs already have a decent tutorial that you should check out if you want more detail. This post is just me having fun and trying to use this new feature to build something with real-world value.
As luck would have it, I was just experiencing some real-world friction that can be used as a test-bed for this very feature: figuring out the total weight that is on a piece gym equipment. To calculate the total weight, I would need to add up the various plates (45 lbs, 25 lbs, 10 lbs, etc) loaded on the machine; and then, add any base-weight for the machine itself.
So, for example, an Olympic barbell with two 45-lb plates would be:
45 (base bar weight) + ( 2 * 45 ) = 135-lbs
The nice thing about this is that the concept is relatively simple; but, it's just complex enough to require a number of different Angular features that are touched by the new standalone components / optional module changes:
- Bootstrapping a root component.
- Consuming a nested component.
- Injecting a service.
Before we look at the code, let's look at a GIF to get a sense of what the UI looks like and how it operates. In the following UI, I'm providing a series of "Add" and "Remove" controls for a given plate weight. As each plate is added to or removed from the calculator, I'm updating the total weight and syncing the state of the UI to the URL:
The bulk of the UI is defined in the AppComponent
. The black circles that show up next to each control is the MeterComponent
. And, the service that syncs the state of the plate weight calculator to the URL is the UrlStateSevice
.
First, let's look at bootstrapping this application in the main.ts
. Unlike a traditional bootstrapping process that uses a module, here I am simply using the root component:
// Import core Angular modules.
import { bootstrapApplication } from "@angular/platform-browser";
import { enableProdMode } from "@angular/core";
// Import application modules.
import { AppComponent } from "./app/app.component";
import { environment } from "./environments/environment";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// NOTE: The module resolution for this file changes depending on the build. See the
// "fileReplacements" option in the angular.json configuration file.
if ( environment.production ) {
enableProdMode();
}
bootstrapApplication( AppComponent ).catch(
( error ) => {
console.warn( "There was a problem bootstrapping the Angular application." );
console.error( error );
}
);
In this case, I'm just calling bootstrapApplication( AppComponent )
with a single argument - my root component; but, this function invocation can take a second argument which configures the application providers. In this demo, since I don't have any routes or dependent modules, there's nothing else to do.
My AppComponent
holds all of the traditional UI and state management that you would find in any run-of-the-mill Angular component. Only, in addition to the normal @Component()
decorator information, I'm going to also define:
standalone: true
imports
The standalone
flag tells Angular that this is a standalone / module-free component. And, the imports
collection tells Angular which components, directives, and pipes can be used as selectors within the current component template. This includes the core Angular directives. And, since we have no root module (in this demo) providing global declarations, my AppComponent
is responsible for explicitly import
'ing the CommonModule
and FormsModule
so that I can use basic directives like *ngIf
, *ngFor
, and [(ngModel)]
within the AppComponent
template.
Here's the full code for AppComponent
; though, the only parts really relevant to the standalone-component concept are in the first 50-lines. Note that my imports
collection contains both core and nested components:
// Import core Angular modules.
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { FormsModule } from "@angular/forms";
// Import application modules.
import { MeterComponent } from "./meter.component";
import { UrlStateService } from "./url-state.service";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
interface Segment {
name: string;
weight: number;
count: number;
}
@Component({
standalone: true,
selector: "app-root",
// NOTE: Because I am not using a root module, there's nothing that inherently tells
// Angular that I expect the core Angular directives to be available in the templates.
// As such, I have to explicitly include the CommonModule, which provides directives
// like `ngIf` and `ngForOf`, in this component's imports.
imports: [
CommonModule,
FormsModule,
// OH SNAP, I'm pulling in local, application components as well!
MeterComponent
],
templateUrl: "./app.component.html",
styleUrls: [ "./app.component.less" ]
})
export class AppComponent {
private urlStateService: UrlStateService;
public baseWeight: number;
public form: {
baseWeight: string;
};
public segments: Segment[];
public total: number;
// I initialize the root component.
constructor( urlStateService: UrlStateService ) {
this.urlStateService = urlStateService;
this.baseWeight = 0;
this.segments = [
this.buildSegment( 45 ),
this.buildSegment( 25 ),
this.buildSegment( 10 ),
this.buildSegment( 5 ),
this.buildSegment( 2.5 )
];
this.total = 0;
this.form = {
baseWeight: ""
};
}
// ---
// PUBLIC METHODS.
// ---
/**
* I add a single plate to the given segment.
*/
public addPlate( segment: Segment ) : void {
segment.count++;
this.setTotal();
this.statePushToUrl();
}
/**
* I apply the current base-weight form value to the overall weight total.
*/
public applyBaseWeight() : void {
this.baseWeight = ( +this.form.baseWeight || 0 );
this.setTotal();
this.statePushToUrl();
}
/**
* I remove all of the plates from all of the segments.
*/
public clearPlates() : void {
for ( var segment of this.segments ) {
segment.count = 0;
}
this.setTotal();
this.statePushToUrl();
}
/**
* I get called once after the component inputs have been bound for the first time.
*/
public ngOnInit() : void {
this.statePullFromUrl();
}
/**
* I remove a single plate from the given segment.
*/
public removePlate( segment: Segment ) : void {
if ( segment.count ) {
segment.count--;
this.setTotal();
this.statePushToUrl();
}
}
// ---
// PRIVATE METHODS.
// ---
/**
* I create an empty segment for the given weight-class.
*/
private buildSegment( weight: number ) : Segment {
return({
name: String( weight ),
weight: weight,
count: 0
});
}
/**
* I calculate and store the total based on the current view-model.
*/
private setTotal() : void {
this.total = this.segments.reduce(
( reduction, segment ) => {
return( reduction + ( segment.weight * segment.count ) );
},
this.baseWeight
);
}
/**
* I pull state from the URL (fragment, hash), and use it to update the view-model.
* This allows the current weight plate confirmation to be shareable with others.
*/
private statePullFromUrl() : void {
var state = this.urlStateService.get();
this.baseWeight = ( state[ "baseWeight" ] || 0 );
this.form.baseWeight = String( this.baseWeight || "" );
// The state is just a set of key-value pairs in which the values are numeric. As
// such, let's iterate over our segments to see if there is a corresponding state
// key for the given weight.
for ( var segment of this.segments ) {
segment.count = ( state[ segment.weight ] || 0 );
}
this.setTotal();
}
/**
* I persist the current plate configuration to the URL fragment so that the current
* state can be shared.
*/
private statePushToUrl() : void {
this.urlStateService.set( this.baseWeight, this.segments );
}
}
As you can see, the AppComponent
is importing the MeterComponent
. This is another tiny standalone component that translates a [value]
input binding into a series of iterative discs (meant to illustrate the plates on the gym equipment). This is beginning to look more like React, where you can import a component and then consume it within your JSX. Of course, Angular then layers on powerful constructs like Dependency-Injection, so it's not an apples-to-apples comparison. But, it looks like we're starting to get the best of both worlds in this Angular 14 update!
In the following code for MeterComponent
, notice that this it also has to import the CommonModule
in order to use core Angular directives, like *ngForOf
, despite the fact that the AppComponent
is also importing said module. That's the whole point of the standalone-component concept: you explicitly pull in what you need; the component becomes the organizational unit.
// Import core Angular modules.
import { ChangeDetectionStrategy } from "@angular/core";
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
standalone: true,
selector: "app-meter",
inputs: [ "value" ],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ CommonModule ],
templateUrl: "./meter.component.html",
styleUrls: [ "./meter.component.less" ]
})
export class MeterComponent {
public readings: number[];
public value!: number;
// I initialize the meter component.
constructor() {
this.readings = [];
}
// ---
// PUBLIC METHODS.
// ---
/**
* I get called when the inputs bindings are first bound or updated.
*/
public ngOnChanges() : void {
this.setReadings();
}
// ---
// PRIVATE METHODS.
// ---
/**
* I populate the readings based on the current view-model.
*/
private setReadings() : void {
this.readings = [];
for ( var i = 1 ; i <= this.value ; i++ ) {
this.readings.push( i );
}
}
}
My AppComponent
also injects a UrlStateService
. This service is essentially untouched by any standalone-component changes. I am still decorating it with @Injectable()
; and, it is still being provided in the root injector (now, apparently, being called an "Environment Injector" - see documentation).
// Import core Angular modules.
import { Injectable } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export interface State {
[ key: string ]: number;
}
export interface Segment {
weight: number;
count: number
}
@Injectable({
providedIn: "root"
})
export class UrlStateService {
/**
* I split the location fragment into a dictionary of key:value pairs in which the
* values are all numbers.
*/
public get() : State {
var state = Object.create( null );
for ( var setting of window.location.hash.slice( 1 ).split( ";" ) ) {
var parts = setting.split( ":" );
var name = parts[ 0 ];
var value = ( +parts[ 1 ] || 0 );
state[ name ] = value;
}
return( state );
}
/**
* I store the given weights into the location fragment.
*/
public set(
baseWeight: number,
segments: Segment[]
) : void {
window.location.hash = segments.reduce(
( reduction, segment ) => {
return( `${ reduction };${ segment.weight }:${ segment.count }` );
},
`baseWeight:${ baseWeight }`
);
}
}
For the sake of simplicity, I am foregoing the use of the Router module and I'm just consuming the native Location
API hash
to store the state.
The component templates aren't affected at all by the standalone component updates in Angular 14. That's the beautiful thing about using a selector based template model: the templates don't care where the components and directives are coming from as long as there is something providing them.
That said, I'll provide the component templates for completeness. Here's the one of the AppComponent
. You'll see that it makes use of <app-meter>
, which is the selector for the imported MeterComponent
:
<h1 class="title">
Plate Weight Calculator
</h1>
<figure class="total">
<div class="total__value">
{{ total }}
</div>
<figcaption class="total__label">
Total Weight in LBS
</figcaption>
</figure>
<ul class="segments">
<li *ngFor="let segment of segments" class="segments__segment segment">
<div class="segment__controls controls">
<button
aria-describedby="add-plate-icon"
(click)="removePlate( segment )"
class="controls__button">
<svg role="img" class="controls__icon">
<title id="add-plate-icon">
Remove {{ segment.weight }}-pound Plate
</title>
<use xlink:href="#icon-minus"></use>
</svg>
</button>
<span aria-hidden="true" class="controls__label">
{{ segment.weight }}
</span>
<button
aria-describedby="remove-plate-icon"
(click)="addPlate( segment )"
class="controls__button">
<svg role="img" class="controls__icon">
<title id="remove-plate-icon">
Add {{ segment.weight }}-pound Plate
</title>
<use xlink:href="#icon-plus"></use>
</svg>
</button>
</div>
<app-meter
[value]="segment.count"
class="segment__meter">
</app-meter>
</li>
</ul>
<div class="base-weight">
<label for="base-weight-input" class="base-weight__label">
Base-weight of equipment:
</label>
<input
id="base-weight-input"
type="number"
[(ngModel)]="form.baseWeight"
(input)="applyBaseWeight()"
class="base-weight__input"
/>
</div>
<button (click)="clearPlates()" class="clear">
Clear weight plates
</button>
And, here's the tiny template for the MeterComponent
:
<span *ngFor="let reading of readings" class="reading">
{{ reading }}
</span>
From what I can see, Standalone components / optional modules isn't fundamentally changing the way Angular works. It's really just changing the developer ergonomics of structuring your application. Ultimately, you still have to tell Angular about all of the building blocks that go into a component. It's just that in many cases, we'll be able to colocate that configuration information with the components instead of having to create separate, somewhat superfluous modules.
Want to use code from this post? Check out the license.
Reader Comments
Thank you for your article Ben, I was wondering if possible to use APP_INITIALIZER token in appcomponent or otherwise without appmodule at all. I have createtd a new project that I removed appmodule.
Is this possible to wait for another service to start before appstart. It doesnt seem to be working.
Thanks in advance.
Hormoz
@Hormoz,
That's a good question. Even without the
NgModule
, you still need amain
file in which to bootstrap the application. In this case, the standalone components usebootstrapApplication()
. I have not tried this myself, but you can still pass providers to this method, in much the same way that we did withNgModule
. So, I assume - but have not tested - that it would be something like:I'll add this to my list of things to try out. I'm also quite eager to try out the routing and lazy-loading in standalone components.
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →