Angular Standalone Components

Angular Standalone Components

There is no 'We', there is only 'I'

Featured on Hashnode

One of the features that Angular v14 provided as developer preview and fully released in Angular v15 is Standalone components.

For me, it was an interesting topic to follow starting from the RFC (which BTW, I encourage every developer to follow as many of them, they are pure gold of knowledge and context) to using it day to day in production projects.

I'll start the article by doing a small recap of what NgModule is and after that, we'll dive deep into Standalone components and figure out what are the advantages of 'standalone'

If you already have a clear grasp of what NgModules and SCAM pattern are, jump straight to Standalone components


NgModule - The starting point

You can look at NgModules as packages that group building blocks (components, directives, pipes and services) together. Also, NgModule is used to register the building blocks to the compiler.

This means each time you create a building block, you'll have to associate it with a module to use it.

Let's take a look at how a NgModule looks like

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

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

Each NgModule has the following properties

  • Declarations - List of building blocks that belong to the module. To have the building blocks available for compilation, you have to add them here

  • Imports - List of other modules that are necessary for the module to work properly

  • Exports - List of building blocks that you make allow to be used when the module is imported by another module. Think of it as private/public.
    If it's only in declarations - private
    If you have it also in exports - public

  • Providers - List of services that can be injected.

  • Bootstrap - What component is going to be used when loading the module

It is a bit counter-intuitive if you're coming from other frameworks. Usually, in other frameworks, your focus is building components. In Angular, you're focusing on modules and how you're grouping building blocks. Which sometimes can be mentally tiresome, especially on big projects.

Now imagine adding another architectural concept like monorepos on top, where you structure your app on libs, which you structure in modules. The mental model becomes bigger and bigger.

Some of the advantages that using NgModules are:

  • Grouping building blocks based on semantics (sounds like cohesion).

  • Lazy-loading modules

  • Tree-shakable module

It sounds good and Angular world worked this way so far. That doesn't mean that everything was smooth sailing.

As the codebase will grow, you will have the following problems:

  • Decision fatigue - Do I need this building block in multiple modules, or do I move it to a third module and import it?

  • Bigger and bigger modules - If these dependencies happen, the shared module will become bigger and bigger. I will have no gain from Lazy-loading and Tree-shakable modules.


SCAM Pattern - The mitigation

Surprisingly, it wasn't a SCAM and we used it.

SCAM (Single Component Angular Module) pattern means that for each component that we're building, we also create a module for it.

Let's have an example and explore what problems it solves:

We have VideoPlayerComponent that we want to use in multiple modules. What the SCAM pattern will do in this case is create a NgModule for this component.

@NgModule({
  declarations: [
    VideoPlayerComponent
  ],
  imports: [
    BrowserModule
  ]
  exports: [
    VideoPlayerComponent
  ]
})
export class VideoPlayerComponentModule { }

When we want to use the VideoPlayerComponent in for example, PostComponent, we're going to import the component as a Module

@NgModule({
  declarations: [
    PostComponent
  ],
  imports: [
    BrowserModule,
    VideoPlayerComponentModule,
  ]
  exports: [
    PostComponent
  ]
})
export class VideoPlayerComponentModule { }

After this import, we would be able to use VideoPlayerComponent inside PostComponent.

What are the improvements here?

  • Lazy-loading at the component level. Gives an even more granular lazy-loading.

  • Not having the problem of keeping track of what module the component should belong to.

The biggest trade-off? Unnecessary boiler-plate. For each component, you'll have an additional class just to use it.

Sure, you can use schematics to generate your module together with the component but at the end of the day, it's still a boilerplate that annoys us.

This boilerplate is still present in the bundled code. If we turn off the optimization, we will see the declaration of the module as a class. Let's say we have a SimpleTableModule that has SimpleTableComponent.

// src/shared/components/simple-table/simple-table.module.ts
var SimpleTableModule = /* @__PURE__ */ (() => {
  const _SimpleTableModule = class {
  };
  let SimpleTableModule2 = _SimpleTableModule;
  (() => {
    _SimpleTableModule.\u0275fac = function SimpleTableModule_Factory(t) {
      return new (t || _SimpleTableModule)();
    };
  })();
....

Standalone components - The shift

Behold, enters the Standalone components.

Standalone components will allow us to make modules optional and jump straight to component creation.

How do I start?

Simple, you'll have three steps to do:

  • Mark the component as standalone
@Component({
    ...
    standalone: true
    ...
})
  • Declare the imports that are required by the component to work correctly
    You can import other standalone components or even modules.
@Component({
    ...
    standalone: true
    imports: [MyNecessaryImportModule, AnotherImportComponent]
    ...
})
  • Import the component where you need it to be used. This can happen either in another standalone component or module.

Let's have a button component for example:

@Component({
  selector: 'my-button',
  // Newly added lines
  standalone: true,
  imports: [CommonModule, MatButtonModule],
  //
  templateUrl: './app-button.component.html',
})
export class ButtonComponent {
  @Input() text = 'Button';
  @Output() buttonClicked = new EventEmitter<any>()

  constructor() {}

  onButtonClicked(event: any) {
    console.log('Button clicked');
    this.buttonClicked.emit(event);
  }
}

We can see that we have standalone: true and also imports: [CommonModule, MatButtonModule]

This shows us that we can import other modules as well.

CommonModule is not mandatory. It will import all of the basic structural directives and pipes that Angular provides. You can go more granular here by importing the directives or pipes you need like these imports: [NgIf, NgFor]

The best thing about standalone components is their interop with modules. They behave very similarly to the SCAM pattern that we talked about before.

Also, another difference is the code that is generated. If in the SCAM case, we will have another class for the module generated, in the case of standalone components there's no need. Smaller bundle size and no more boilerplate.

I'm using lazy-loading modules routing in my application. Is that still available?

Of course!

Remember, your component will act the same as a module but here we have a change in how we declare it.

Before, we used to have this declaration:

export const routes: Routes = [
  { path: '', loadChildren: () => import('./pages/home/home.module').then((m) => m.HomeModule) },
  { path: 'login', loadChildren: () => import('./pages/login/login.module').then((m) => m.LoginModule)}
]

loadChildren requires a module to work and also you'll have to declare the entry component of each of the modules.

Not anymore. Now we can use loadComponent instead

export const routes: Routes = [
  { path: '', loadComponent: () => import('./pages/home/home.component').then((c) => c.HomeComponent) },
  { path: 'login', loadComponent: () => import('./pages/login/login.component').then((c) => c.LoginComponent)}
]

Don't mistake loadComponent with component.

  • loadComponent will lazy-load your component

  • component will eagerly load your component.

Can I go 'Module-less'?

Yes!

Now in Angular, you have the option to bootstrap the application to a component

All the magic happens in main.ts file

Before having standalone components, the file would likely look like this. You would need to pass a module that has an entryComponents or some routes declared inside of it

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

Now, you can do it using this

export const routes: Routes = [
  { path: '', loadComponent: () => import('./pages/home/home.component').then((c) => c.HomeComponent) },
  { path: 'login', loadComponent: () => import('./pages/login/login.component').then((c) => c.LoginComponent)}
]

bootstrapApplication(MyStandaloneComponent, {
    providers: [
      provideRouter(routes),
    ]
})

In this case, MyStandaloneComponent will act as the entry point of your app and the routes are declared in the providers.

By doing this, you can forget that modules are existing in Angular and go full 'module-less'. Pretty cool!

Is there any easy way to migrate from modules to standalone components?

Luckily, it is.

Taking a look inside the Angular's repository, we'll discover the schematic:

@angular/core:standalone

This schematic will help us migrate our module-based components to standalone ones. It comes in handy and you can also do partial migrations so you will not have to do the 'mega-merge'.

I will quickly summarize the steps here but I highly recommend reading the schematics readme:

  • Convert all components, directives and pipes to standalone

  • Remove unnecessary NgModule classes

  • Bootstrap the application using standalone APIs

Any best practices?

I don't consider them best practices but I can tell you what worked for me so far.

  1. If you're creating new components, directives or pipes, go straight to the standalone component. You can easily use Angular schematics to generate one

     ng generate component --standalone pizza
    

    You can go even further and just change your angular.json to add the standalone flag by default.

     ...
     "projects": {
         "my-project": {
           "projectType": "application",
           "schematics": {
             "@schematics/angular:component": {
               "standalone": true
             }
           }
     }
     ...
    

    I'm recommending this because as you will use it more and more, it will become trivial to create components and I'm sure you will not go back to modules.

  2. If you want to migrate your components, use the schematics for the migration.
    Start small, especially if you have a big project to handle.

    • Migrate pipes. They were the easiest to migrate.

    • Migrate SCAMs. All you will have to do is get rid of the module and use the component in the imports instead.

    • As you advance in your migration process you will get used to it and can easily migrate some other bigger modules.

  3. A problem that I had in migrating some more business-intense components was the unit tests. If you're trying to mock imported modules, you will have a hard time at first.
    For this one, I recommend reading this article on how to create shallow tests for standalone components. It helped me a lot.

  4. You can still keep your application structure as it was with modules. If it worked with modules, it will work without them as well.


Conclusion

I liked exploring this new feature and I'm amazed by the interop with modules. You can start using it to build new components without having any problems with backward compatibility.

It became more simple to write Angular with standalone components. I'm not going back to NgModules and I will try to avoid them as much as possible.

Great feature and I'm sure that it will unlock other features, patterns and other best practices in the future.

Also, if you want to further extend your investigation, I highly recommend the following sources: