Standalone Angular Components - A world without ngModule

Standalone Angular Components - A world without ngModule

Know about Angular standalone components and how it simplifies your Angular code

Angular has come a long way since its inception, widespread use in its first incarnation(a.k.a Angular JS), and today being embraced as the de-facto one-stop framework to build a modern enterprise-ready, browser-based applications/SPAs. One thing that I personally appreciate, more than anything between its different major releases is its focus on developer productivity, ability to innovate(and re-invent) without comprising on the aspects that let us build robust, modular, and highly scalable applications. But, achieving a fine balance between developer experience and knowing enough about your code to infer, and guarantee robustness is easier said than done. A one-stop tool doesn’t need to worry just about the essentials to get you going but also needs to be meticulously focused on giving you the right foundations for you to orchestrate an entire system that scales both for new code as well as pre-existing, large codebases. NgModule is one such construct that informs Angular of the different logical(and by effect, physical) boundaries of your code. It lays out the foundation to separate concerns by features, declare dependencies, encapsulate logic, expose what’s accessible outside its realm, etc. that can be stitched together to make an entire Angular application. As an auxiliary need, introduce concepts like lazy loading, and bootstrap application through one true AppModule. What components do for the presentation layer is what ngModule does for Angular.

But, ngModule has garnered quite a love/hate relationship with developers over time...okay, largely love ❤️, but there are aspects that leave things to be desired. In this article we’ll know how Angular’s enhancing the ngModule experience through Angular Standalone components, a key paradigm shift in how we structure Angular code. But before diving into that, let’s understand how we got here.

Developer Challenges with ngModule

ngModule introduces an additional learning curve, requires extra configurations, and takes a bit of work before you get a component’s structure. By their definition and use, components, directives, and pipes can be assumed standalone within the module boundaries. To carry beyond boundaries, you’ll have to carry all of the module - a bag full of goodies. It has served us quite well but it leads to a bit of limiting development experience, and missing a bit of discipline and forethought makes taking decisions and navigating freely harder as your app grows. You get some readability benefits at the ngModule level but lose some of the portability, discover friction, bugs and bottlenecks in scaling the app, or improving its performance. The additional ceremony(and overhead), although necessary seemingly leads to a learning curve and sometimes, relatively more complex, less appealing or easy enough for beginners.

The trouble with ngModule increases with the size and complexity if you’re not upfront aware and thoughtful of how your App's gonna evolve. It requires good developer care and good gate-keeping to keep complexity and app size bloat at bay.

To deal with some of the mentioned shortcomings people came up with concepts like SCAM(Single Component Application Module)

SCAM - Single Component Application Module

SCAM is a community-envisioned approach to co-locate components and ngModule to reduce the amount of work needed to organise and understand a component in its entirety. you place ngModule in the same file as your component and focus on single-responsibility and more modular components and modules to increase port-ability of common behavioural/presentational code beyond the logical fences. This approach re-invents the app writing style in the user-land, and aids in providing flexibility with organising code, lazy loading more atomic chunks, etc. but doesn't change anything much about how Angular works and still expects everything. You can know more about the SCAM approach here and here.

Standalone Components/Pipes/Directives

Standalone components are Angular's take on simplifying Angular code and are an answer to the aforementioned concerns. Angular is known for course correcting and continuously innovating the developer ergonomics. With Angular 14, as developers, you’d get an opportunity to say tada 👋 to ngModule for most of your components, directives, pipes, etc. Your components, directives, and pipes can be more of a single-file components/directives/pipes(or, single source of truth), with everything essential to them co-located.

With Angular’s standalone component’s role known, let’s see how we can use it today!

Working with Standalone Components today

To start a new ng-14 project today, run the following command in your terminal

npx @angular/cli@next new ng14

This will scaffold a new Angular project for you(with 14.0.0-rc.0), with the conventional structure - One AppComponent

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'ng14';
}

included in one AppModule

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

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

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

initialised by your main.ts


import { AppModule } from './app/app.module';

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));

With standalone support you can get rid of the AppModule entirely by turning the standalone property on your AppComponent to be true

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [ CommonModule],
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'ng14';
}

This single property will make your component more self-reliant in terms of declaring what they need to operate. As app.module.ts is not needed anymore, the work it used to do becomes the responsibility of your standalone components. A standalone component should declare everything, all the common Angular provided feature it needs in the imports array.

@Component({
  // ...
  standalone: true,
  imports: [
        CommonModule, 
        RouterModule, 
        SomeCommunityModule, 
        SomeStandaloneComponent, 
        SomeStandaloneDirective, 
        SomeStandalonePipe
    ],
  templateUrl: './your-standalone.component.html' 
  // can use features made available by above imports like  `*ngIf`, `routerLink`, etc.
})
export class StandaloneComponent {
}

Similar to standalone modules, you can have standalone pipes and directives working on the same standalone premise.

Bootstrapping with Standalone Components

Since your application, is now a standalone component(not a module) right from the root/top you’re not required to bootstrap the old way. You use bootstrapApplication from @angular/platform-browser to kickstart booting your AppComponent . The bootstrapApplication takes the root standalone component as an argument

import { bootstrapApplication } from '@angular/platform-browser';

import { AppComponent } from './app/app.component'
import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

bootstrapApplication(AppComponent)
  .catch(err => console.error(err));

With ngModule left redundant, and imports being owned by the standalone entities, you’d wonder how providers could be made available to your root component and all its children. This is where the second parameter to bootstrapApplication comes into picture.

import { enableProdMode, importProvidersFrom } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http'

import { AppComponent } from './app/app.component'
import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

bootstrapApplication(AppComponent, {
    providers: [
        importProvidersFrom(HttpClientModule)
    ]
})
  .catch(err => console.error(err));

**importProvidersFrom is a special function(only available to bootstrapApplication) that lets you extract providers from the provided module. You can use it to provide providers from HttpClientModule, or RoutingModule or any module provider you’d like to make available application-wide.

💡 BrowserModule providers are automatically included when you use bootstrapApplication.

With app.module left redundant you can now remove it, or If you want to incrementally adopt standalone features, keep the app.module.ts and instead of providing the AppComponents in declaration put it just inside the imports array of the AppModule

NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, HttpClientModule, YourStandaloneComponent ], // <---
  bootstrap: [AppComponent]
})
export class AppModule {}

Working with Routes

With the concept of app.module gone, you can define routes inside your standalone components or route specific *.route|routing.ts, whatever fits your project needs.

Say that you want to render a list of notes on the /notes route, you can configure it by providing

export const AppRoutes: Routes = [{
    path: 'notes',
    component: NoteListComponent
}]

in the app.component.ts and use it in the main.ts by doing the following changes

import { enableProdMode, importProvidersFrom } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http'

import { AppComponent, AppRoutes } from './app/app.component'
import { environment } from './environments/environment';
import { RouterModule } from '@angular/router';

if (environment.production) {
  enableProdMode();
}

bootstrapApplication(AppComponent, {
    providers: [
        importProvidersFrom(HttpClientModule),
        importProvidersFrom(RouterModule.forRoot(AppRoutes))
    ]
})
  .catch(err => console.error(err));
ℹ️ Providers of the top-level routes need to be extracted and provided while bootstrapping for routing to behave properly.

Lazy loading components

The structure we used before paves way for lazy loading. Since it’s components not modules as the root entity, we have a very aptly named loadComponent that marks components to be lazy loaded when visited. The previous structure can be re-written as

export const AppRoutes: Routes = [{
    path: 'notes',
    children: [
        {
            path: '',
            loadComponent: () => import('./note-list/note-list.component').then((m) => m.NoteListComponent)
        },
        {
            path: ':id',
            loadComponent: () => import('./note/note.component').then((m) => m.NoteComponent)
        }
    ]
}]

As you can see, we’re neither required to maintain a module to associate routing definitions, or use loadChildren and it greatly simplifies the routing configuration. loadChildren isn’t made redundant but comes handy when you need to load multiple routes lazily.

{ 
  path: 'notes',
  loadChildren: () => import('./note.routes').then(c => c.noteRoutes)
}

If you want to configure route-level providers, that’s as easy as configuring the route-level providers array parallel to the path and load properties. Route definition support nesting, and can be described as they used to be described previously.

export const noteRoutes: Routes = [
  { 
    path: '',
    pathMatch: 'prefix',
    providers: [ NoteService ], // <-- include providers here...
    children: [
        {
            path: '',
            component: NoteListComponent
        },
        {
            path: ':id',
            component: NoteComponent
        }
    ]
  }
];

CLI Support

You can easily generate standalone components(or pipe, directives) using the --standalone flag as follows

ng g component|pipe|directive --standalone your-sa[c/p/d]

or enable it in schematics, for having all components standalone

"schematics": {
  "@schematics/angular:component": {
    "standalone": true
  }
}

Wrapping up

Standalone components are a breath of fresh air and simplify the mental model of an Angular app’s structure. It brings Angular closer to how components are seen and managed in other modern frontend frameworks and opens ways for the community to contribute more towards embracing tooling that can directly work with more self-contained entities. This modern change, paves a way to innovate more in the Angular ecosystem, and lays the foundation for pretty exciting changes like component-first architecture and integration with tooling beyond Webpack a bit less frustrating affair. Read more about Standalone APIs RFC here and here.