A guide to Standalone Components in Angular

Angular v14 introduces one major (experimental) feature, after months of discussion: the possibility to declare standalone components/pipes/directives, and to get rid of NgModule in your application if you want to 😍.

In this article, we’ll see:

  • how to declare standalone entities,
  • how to use them in existing applications
  • how to get rid of NgModule if you want to
  • how the router has changed to leverage this new feature
  • and more nerdy details!

Disclaimer: this blog post is based on early releases of Angular v14, and some details may change based on the feedback the Angular team gets. That’s why, for once, we write a blog post on a feature before its final release: this is a great opportunity to give it a try and gather feedback!

Standalone components

Components, directives, and pipes can now be declared as standalone.

@Component({
  selector: 'ns-image',
  standalone: true,
  templateUrl: './image.component.html'
})
export class ImageComponent {
}

When that’s the case, the component/directive/pipe can’t be declared in an NgModule. But it can be directly imported into another standalone component. For example, if my ImageComponent above is used in the template of a standalone UserComponent, you have to import ImageComponent in UserComponent:

@Component({
  selector: 'ns-user',
  standalone: true,
  imports: [ImageComponent],
  templateUrl: './user.component.html' 
  // uses `<ns-image>`
})
export class UserComponent {
}

This is true for every component/directive/pipe you use in a standalone component. So if the template of UserComponent also uses a standalone FromNowPipe and a standalone BorderDirective, then they have to be declared into the imports of the component:

@Component({
  selector: 'ns-user',
  standalone: true,
  imports: [ImageComponent, FromNowPipe, BorderDirective],
  templateUrl: './user.component.html' 
  // uses `<ns-image>`, `fromNow` and `nsBorder`
})
export class UserComponent {
}

This is also true for components, directives, and pipes offered by Angular itself. If you want to use ngIf in a template, the directive has to be declared. But ngIf is not a standalone directive: it is offered via the CommonModule. That’s why imports lets you import any NgModule used as well:

@Component({
  selector: 'ns-user',
  standalone: true,
  imports: [CommonModule, RouterModule, ImageComponent, FromNowPipe, BorderDirective],
  templateUrl: './user.component.html' 
  // uses `*ngIf`, `routerLink`, `<ns-image>`, `fromNow` and `nsBorder`
})
export class UserComponent {
}

You can of course import your own existing modules or modules offered by third-party libraries. If you use the DragDropModule from Angular Material for example:

@Component({
  selector: 'ns-user',
  standalone: true,
  imports: [CommonModule, RouterModule, DragDropModule, ImageComponent],
  templateUrl: './user.component.html' 
  // uses `*ngIf`, `routerLink`, `cdkDrag`, `<ns-image>`
})
export class UserComponent {
}

A standalone component can also define schemas if you want to ignore some custom elements in its template with CUSTOM_ELEMENTS_SCHEMA or even ignore all errors with NO_ERRORS_SCHEMA.

Usage in existing applications

This is all great, but how can we use our new standalone UserComponent in an existing application that has no standalone components?

Maybe you guessed it: you can import a standalone component like UserComponent in the imports of an NgModule!

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

This is probably a sound strategy to start using standalone components, pipes, and directives in existing applications. Angular applications tend to have a SharedModule with commonly used components, directives, and pipes. You can take these and convert them to a standalone version. It’s usually straightforward, as they have few dependencies. And then, instead of importing the full SharedModule in every NgModule, you can import just what you need!

CLI support

The Angular CLI team added a new flag --standalone to ng generate in v14, allowing to create standalone versions of components, pipes, and directives:

ng g component --standalone user

The component skeleton then has the standalone: true option, and the imports are already populated with CommonModule (that will be used in pretty much all components anyway):

@Component({
  selector: 'pr-user',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './user.component.html',
  styleUrls: ['./user.component.css']
})
export class UserComponent implements OnInit {

The generated test is also slightly different. A standalone component is declared in the imports option of TestBed.configureTestingModule() instead of in the declarations option.

If you want to generate all components with the --standalone flag, you can set the option directly in angular.json:

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

You can of course do the same for the directive and pipe schematics.

Application bootstrap

If you want to, you can go one step further and write an application with only standalone entities, and get rid of all NgModules. In that case, we need to figure out a few details.

First, if we don’t have an Angular module, how can we start the application? A typical main.ts contains a call to platformBrowserDynamic().bootstrapModule(AppModule) which bootstraps the main Angular module of the application.

In a standalone world, we don’t want to use NgModule, so we don’t have an AppModule.

Angular now offers a new function called bootstrapApplication() in @angular/platform-browser. The function expects the root standalone component as a parameter:

bootstrapApplication(AppComponent);

This creates an application and starts it.

For SSR, you can use the new renderApplication function, which renders the application as a string:

const output: string = await renderApplication(AppComponent, { appId: 'app' });

Optional NgModules

NgModule is a weird concept in Angular if you think about it. They fulfill several roles at once. We use them to declare what is usable in the templates of the components, but also to configure the available providers. We can export entities, to make them available in other modules. Modules are eagerly executed, which means you can add code in their constructors if you want to run something on their initialization. They are also necessary if you want to lazy-load parts of your application.

If modules are now optional, how can we do all these tasks?

Providers

NgModules allow defining providers available for components in the module. For example, if you want to use HttpClient, you add HttpClientModule to the imports of your main module.

In an application with no module, you can achieve the same by using the second parameters of bootstrapApplication(), which allows declaring providers:

bootstrapApplication(AppComponent, { providers: [] });

In the long run, Angular will probably offer a function returning the HTTP providers. For now, to bridge the gap with modules that expose providers, we can use importProvidersFrom(module):

bootstrapApplication(AppComponent, { 
  providers: [importProvidersFrom(HttpClientModule)]
});

You can also use importProvidersFrom to configure the router:

bootstrapApplication(AppComponent, { 
  providers: [importProvidersFrom(RouterModule.forRoot([/*...*/]))]
});

Note that the BrowserModule providers are automatically included when starting an application with bootstrapApplication().

It’s also worth noting that you can’t use importProvidersFrom in component providers: it’s only usable in bootstrapApplication(). bootstrapApplication() is now responsible for the Dependency Injection work, and that’s where providers must be declared.

Note: since Angular v15, it’s now possible to use provideRouter() and provideHttpClient() (see our blog post about Angular HTTP in a standalone application).

Lazy loading routes

The lazy-loading story in Angular has always revolved around NgModule. Let’s say you wanted to lazy-load an AdminComponent. You had to write an NgModule like the following:

@NgModule({
  declarations: [AdminComponent],
  imports: [
    CommonModule, 
    RouterModule.forChild([{ path: '', component: AdminComponent }])
  ],
})
export class AdminModule {}

and then load the module with the router function loadChildren:

{ 
  path: 'admin',
  loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)
}

You can now get rid of AdminModule if AdminComponent is standalone, and directly lazy-load the component with loadComponent:

{ 
  path: 'admin',
  loadComponent: () => import('./admin/admin.component').then(m => m.AdminComponent)
}

This is a really nice addition! All the lazy-loaded components must be standalone of course. It’s worth noting that this feature exists in all other mainstream frameworks, and Angular was lacking a bit on this.

We can also lazy-load several routes at once, by directly loading the routes config with loadChildren:

{ 
  path: 'admin',
  loadChildren: () => import('./admin/admin.routes').then(c => c.adminRoutes)
}

We now have a nice symmetry between children/loadChildren and component/loadComponent!

But NgModules also allow to define providers for a lazy-loaded module: the providers are then only available in the components of the lazy-loaded module. To achieve the same thing, you can now declare providers directly on a route, and the providers will be available only for this route and its children:

{ 
  path: 'admin',
  providers: [AdminService],
  loadComponent: () => import('./admin/admin.component').then(c => c.AdminComponent)
}

This works with all types of routes (with component, loadComponent, children, loadChildren with routes or NgModule). In my example above, the component is lazy-loaded, but the service is not. If you want to lazy-load the service as well, you can use:

{ 
  path: 'admin',
  loadChildren: () => import('./admin/admin.routes').then(c => c.adminRoutes)
}

and define the providers in adminRoutes:

export const adminRoutes: Routes = [
  { 
    path: '',
    pathMatch: 'prefix',
    providers: [AdminService], // <--
    children: [
      { path: '', component: AdminComponent }
    ]
  }
];

Initialization

An NgModule can also be used to run some initialization logic, as they are eagerly executed:

@NgModule({ /*...*/ })
export class AppModule {
  constructor(currentUserService: CurrentUserService) {
    currentUserService.init();
  }
}

To achieve the same without a module, we can now use a new multi-token ENVIRONMENT_INITIALIZER. All the code registered with this token will be executed during the application initialization.

bootstrapApplication(AppComponent, {
  providers: [
    {
      provide: ENVIRONMENT_INITIALIZER,
      multi: true,
      useValue: () => inject(CurrentUserService).init()
    }
  ]
});

Note that importProvidersFrom(SomeModule) is smart enough to automatically register the initialization logic of SomeModule in ENVIRONMENT_INITIALIZER.

Angular compiler and Vite

On a low level, NgModules are the smallest unit that the compiler can re-compile when running ng serve. Indeed, if you update the selector of a component for example, then the Angular compiler has to check all the templates of the module that contains this component to see if something changed, and also all the modules that import that module. Right now, the Angular compiler is tightly coupled with the TypeScript compiler and does a lot of bookkeeping to only recompile what’s necessary. In an application with no NgModules, the compiler has a more straightforward task: it will for example only recompile the components that directly import the modified component.

This can be good news for the future of Angular tooling. The frontend world has been taken by storm by Vite. We talked about Vite, and the differences with Webpack, in this blog post.

TL;DR: Vite only re-compiles the files needed to display a page and skips the TypeScript compilation to only do a simple transpilation, often in parallel.

This works great for Vue, React, or Svelte, but not so great for Angular, where a lot more needs to be recompiled, and where TypeScript is needed. Standalone components are a nice step in this direction, and may allow a future Angular CLI with Vite instead of Webpack and way faster re-builds.

Caveats

To be honest, the standalone API feels great. We migrated a few applications, and this is really nice to use, and it feels good to get rid of NgModules!

A few pain points though.

  1. There are no “global imports”: you need to import a component/pipe/directive every time you use it. ngIf, ngFor, and friends are available in every standalone component generated by the CLI, as the skeleton includes the import of CommonModule. But routerLink for example is not: you need to import RouterModule if you need it. Other frameworks, like Vue for example, allow registering some components globally, to avoid importing them over and over. That’s not the case in Angular.

  2. Sometimes you forget to add an import, and your template doesn’t work, with no compilation error. For example, adding a link with [routerLink]="['/'] does not compile, but routerLink="/" does compile (and doesn’t work). I feel that these kinds of errors happen more often than they did with NgModule. IDEs will probably help us here, and I suppose typing routerLink in a template will result in an automatic addition of RouterModule in the component’s imports in VS Code/Webstorm/whatever is a few months.

  3. You can’t bootstrap multiple components at once with the new bootstrapApplication() function, whereas it was possible with the NgModule-based bootstrap.

  4. TestBed works with standalone components, but will probably include more specific APIs to simplify tests in the future. Note that it is already easier to test standalone components than classic components, as you don’t have to repeat in configureTestingModule all the dependencies the component needs.

Summary

Six years after the initial release, we can finally get rid of NgModule in Angular if we want to. Their addition to Angular was a bit rushed: they were introduced in Angular v2.0.0-rc.5 two months (!!) before the stable release, mainly to help the ecosystem build libraries. As often in our field, the rushed design resulted in an entity that mixed several concerns, with some concepts quite hard to understand for beginners.

The new “mental model” is easy to grasp: providers are declared on an application level and components just have to import what they need in their templates. It will also probably be easier for newcomers to understand how Angular works.

These standalone APIs are trying to make things clearer, and it looks like they did ♥️.

All our materials (ebook, online training and training) are up-to-date with these changes if you want to learn more!



blog comments powered by Disqus