Sekrab Garage

Angular Standalone Feature

How to turn an Angular app into standalone - Part II

Angular October 28, 22
Series:

Standalone

In this article, we will turn some components in an existing Angular app into standalone components, to see if this feature is worth the trouble. What else do they need to work properly? What scenarios are they best used for? How do they affect development experience? and Output bundles?
  1. two months ago
    How to turn an Angular app into standalone - Part I
  2. one month ago
    How to turn an Angular app into standalone - Part II
  3. one month ago
    Angular standalone router providers: an update
  4. 11 days ago
    Angular 15 standalone HTTPClient provider: Another update

Today we will attempt to get rid of all NgModule in our simple app. To make sure we cover a few angles, we will spice it up with a ProjectService that is provided in root, and a LOCALE_ID token provided in the app root module. We will also create a simple form and use the HTTP client.

The plan is:

  • Get rid of SharedModule
  • Make the content route fully standalone and lazy-load it
  • Get rid of the ProjectRoutingModule and its dependencies
  • Bootstrap a standalone root component
  • Run with injection tokens and providers

The final project is on StackBlitz

Getting rid of the SharedModule

As we covered previously; to have another shared array of components that are imported throughout the application may not be a great win. The better approach is to import only the required component, directive or pipe, when needed. Nevertheless, for demo purposes let’s create a standalone alternative of SharedModule:

// core/shared.module
// the current way was to create a module and add all common components in declarations
@NgModule({
  imports: [
    CommonModule
  ],
  declarations: [
    StarsPartialComponent,
    PagerPartialComponent,
  ],
  exports: [
    StarsPartialComponent,
    PagerPartialComponent,
    CommonModule
  ],
})
export class SharedModule {}

The new shared components look like this

// core/shared.const.ts
// export all shared modules is to create a const
export const SHARED_COMPONENTS = [
  // add common standalone components, turn them all to standalone
  StarsPartialComponent,
  PagerPartialComponent
] as const;

Lazy loading the content module

We had a Content lazy loaded route with two routes, one of them was already standalone. First, let’s make a test: turn a lazy-loaded route into standalone, without changing the components included.

// app.route.ts module
const AppRoutes: Routes = [
  // ..
  // let's turn this into standalone by importing the routes const instead
  {
    path: 'content',
    loadChildren: () =>
      // import('./routes/content.route').then((m) => m.ContentRoutingModule),
    	// import routes instead
  	  import('./routes/content.route').then((m) => m.ContentRoutes),
	},
];
@NgModule({
  imports: [RouterModule.forRoot(AppRoutes)],
  exports: [RouterModule],
})
export class AppRouteModule {}

The ContentRoutes array looks like this

// routes/content.route 
export const ContentRoutes: Routes = [
  {
    // not yet turned into standalone
    path: 'showcard',
    component: ContentShowcardComponent,
  },
  {
    // standalone
    path: 'standalone',
    component: ContentStandaloneComponent,
  },
];

This compiles and builds just fine. But it will fail to run, so be careful. It would fail the unit tests, but if you do not include basic tests (the type that would fail in design time), this might slip. This is one of the shortcomings of the standalone feature.

Then let’s turn all child components to standalone.

Our standalone components with too much freedom

In the above section we turned all standalone child components into a route const, we can also route directly to any of these components from anywhere in the system. Here is an example of routing to ContentStandaloneComponent from the app root:

// app.route module
// ...
const AppRoutes: Routes = [
  // lazy loaded group of sub routes
  {
    path: 'content',
    loadChildren: () =>
      import('./routes/content.route').then((m) => m.ContentRoutes),
  },
  // lazy loaded component, from the same group above
  {
    path: 'lazyloadedstandalone',
	  // notice the new loadComponent function
    loadComponent: () =>
      import('./components/content/standalone.component').then(
        (m) => m.ContentStandaloneComponent
      ),
  },
];

This means we can load the component with two routes:

/content/standalone

/lazyloadedstandalone

With modules, we were able to do that whether in the same module or exported modules. But that was a bad idea already. Now that the restriction of modules is gone, the bad habit may go out of control. So curb your enthusiasm, or stay Module-bound.

Getting rid of a feature route module

For demo purposes let’s add a /projects/create route that contains a partial component with a form. We’ll also use an HTTP call (look inside the ProjectService to find it).

// the current ProjectRoutingModule with a forms route
const routes: Routes = [
  {
    path: '',
    component: ProjectListComponent,
  },
  // new path with a form and an http request
  {
    path: 'create',
    component: ProjectCreateComponent
  },
  {
    path: ':id',
    component: ProjectViewComponent,
  }
];

The create component needs HttpClientModule, and the form partial needs ReactiveFormsModule, we will import the former in the app root module and the latter in the ProjectRoutingModule directly. And the ProjectService shall be provided in root for now. Let’s rip down the ProjectRoutingModule one piece at a time.

The simple list baggage

The process of turning the components into standalone is the same, we simply need to copy over all dependencies into the imports array, here is how the Project list component now looks like:

@Component({
  templateUrl: './list.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [
    // import everything, seriously?
    ProjectCardPartialComponent,
    RouterModule,
    LetDirective,
    CommonModule,
    ...SHARED_COMPONENTS,
  ],
})
export class ProjectListComponent implements OnInit { 
  // ...
} 

The forms partial component looks interesting:

@Component({
  // ...
  // if this is standalone it needs only the things it uses, not the parent
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
})
export class ProjectFormPartialComponent implements OnInit {
  // ...
}

And the ProjectCreateComponent that houses it looks like this

// projects/create.component 
@Component({
  templateUrl: './create.html',
  standalone: true,
  // notice we do not import the forms module here,
  imports: [ProjectFormPartialComponent],
})
// ...

Now the ProjectRoutingModule has no declarations, we’ll turn the module into an exported ProjectRoutes const and drop the NgModule

// routes/project.route becomes simple exported const
export const ProjectRoutes: Routes = [
 //...
];

// and app.routes will lazy load this routes variable
{
  path: 'projects',
  loadChildren: () =>
    import('./routes/project.route').then((m) => m.ProjectRoutes),
},

The service instance need not be changed, and HttpClientModule still works. If we want to stop providing the ProjectService in root we can add it to the providers array of the route, either on the single route that uses it or the parent lazy loaded route. The difference is how shared that instance throughout the application we need it to be.

// routes/project.route becomes simple exported const
export const ProjectRoutes: Routes = [
 //...
];

// and app.routes will lazy load this routes variable
{
  path: 'projects',
  loadChildren: () =>
    import('./routes/project.route').then((m) => m.ProjectRoutes),
},

Since we are still using the root app module, the HttpClientModule can still be imported in it. So what if we want to drop the app module?

Tear down the AppModule

Today we bootstrapModule in the main.ts to bootstrap the main component. If we want to strip all of our application of modules, we can use the new function bootstrapApplication.

// in main.ts, we bootstrap
bootstrapApplication(AppComponent);

In order to pass our app routes, we need to use importProvidersFrom. Which is Angular’s way of saying: here is a band-aid!

Though I still cannot see how in the future we would be able to create routes without RouterModule
Update: we can now use provideRouter
Read about it in the next episode
// main.ts bootstrap passing all providers from an existing NgModule
bootstrapApplication(AppComponent, {
  providers: [
    // pass the routes from existing RouterModule
    // the AppRoutes now is module-free
    importProvidersFrom(RouterModule.forRoot(AppRoutes)),
  ],
});

That leaves the App.Component, to turn it to standalone, we only need to import what it needs:

// app.component
@Component({
  selector: 'my-app',
  // ...
  standalone: true,
  // I need only the router-outlet, routerLink, and the gr-toast partial component
  imports: [RouterModule, ToastPartialComponent],
})
export class AppComponent {
// ...
}

Now we can do the same for HttpClientModule, we can provide in any route, whether in main.ts, in the app.route or in project.route. It is still a good idea to have it on root thought.

// locations to provide the HttpClientModule
// in main.ts
bootstrapApplication(AppComponent, {
  providers: [
    importProvidersFrom(RouterModule.forRoot(AppRoutes), HttpClientModule),
    // ...
  ],
});

// in app.route
export const AppRoutes: Routes = [
  {
    path: 'projects',
    loadChildren: () =>
	    import('./routes/project.route').then((m) => m.ProjectRoutes),
	    // provide httpclient here as well
	    providers: [ProjectService, importProvidersFrom(HttpClientModule)],
  },
// ...
]

// or in routes/project.route for a specific route
export const ProjectRoutes: Routes = [
  {
    path: 'create',
    component: ProjectCreateComponent,
    // all what's needed for the service to work inside this route
    providers: [ProjectService, importProvidersFrom(HttpClientModule)],
  },
  // ...
];

So there you have it, importProvidersFrom is like the cardigan you take when you go out in winter just in case.

Following are more features I tried out on a larger local project, and the changes I had to adopt.

Debloating the main.ts

We can pass all configuration of the RouterModule (like initialNavigation settings) as we did before, we can also provide all required providers directly into main.ts bootstrapper, but now this is how it looks like:

// a real case of standalone main.ts
bootstrapApplication(AppComponent, {
  providers: [
    // pass the routes from existing RouteModule
    importProvidersFrom(RouterModule.forRoot(AppRoutes, {
      preloadingStrategy: PreloadService,
      paramsInheritanceStrategy: 'always',
      onSameUrlNavigation: 'reload',
      scrollPositionRestoration: 'disabled',
      initialNavigation: 'enabledBlocking'
    }), HttpClientModule),
    Title,
    { provide: LOCALE_ID, useClass: LocaleId },
    { provide: RouteReuseStrategy, useClass: RouteReuseService },
    { provide: TitleStrategy, useClass: CricketTitleStrategy },
    { provide: APP_BASE_HREF, useClass: RootHref }
	   // ... other stuff like APP_INITIALIZER, HTTP_INTERCEPTORS...
  ],
});

If we insist on divorcing all NgModule it’s simpler to export arrays from different files.

We can have multiple importProvidersFrom in the same providers array
// alternative CoreProviders in a separate file (example)
export const CoreProviders = [
  // yes we can have multiple importProvidersFrom
  importProvidersFrom(HttpClientModule),
  Title,
  {
    provide: APP_INITIALIZER,
    useFactory: configFactory,
    multi: true,
    deps: [ConfigService]
  },
  {
    provide: HTTP_INTERCEPTORS,
    useClass: AppInterceptor,
    multi: true,
  },
  { provide: ErrorHandler, useClass: AppErrorHandler }
];

// in app route we can contain AppRouteProviders
export const AppRouteProviders = [
  importProvidersFrom(RouterModule.forRoot(AppRoutes, {
    // ...
  })),
  { provide: RouteReuseStrategy, useClass: RouteReuseService },
  { provide: TitleStrategy, useClass: CricketTitleStrategy },
];

// in main.ts: it becomes less bloated
bootstrapApplication(AppComponent, {
  providers: [
    { provide: LOCALE_ID, useClass: LocaleId },
    { provide: APP_BASE_HREF, useClass: RootHref },
    ...AppRouteProviders,
    ...CoreProviders,
  ],
});

The alternative to Module class constructor

In the current module-full setup we had the opportunity to run a script when the module was first injected through constructors. Now that we have no constructors, Angular suggests using an existing but undocument token: ENVIRONMENT_INITIALIZER. We can use that token within the array of providers, to call scripts in similar fashion. Here is an example.

// in current module-full, in the AppRoutingModule
// the constructor injects events and observes changes
@NgModule({
  imports: [
    RouterModule.forRoot(AppRoutes, {
      scrollPositionRestoration: 'disabled'
    })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule {
  // how do we replace this logic in the new standalone 
  constructor(
    router: Router
  ) {
    router.events.pipe(
      filter(event => event instanceof Scroll)
    ).subscribe({
      next: (e: Scroll) => {
        // some logic
      }
    });
  }
}

This can be done inside the AppRouteProviders as follows

// app new standalone routing providers
export const AppRouteProviders = [
  importProvidersFrom(RouterModule.forRoot(AppRoutes, {
    scrollPositionRestoration: 'disabled'
  })),
  {
    // use environment injector
    provide: ENVIRONMENT_INITIALIZER,
    multi: true,
    useValue() { 
      // inject first
      const router = inject(Router);
      router.events.pipe(
        filter(event => event instanceof Scroll)
      ).subscribe({
        next: (e: Scroll) => {
          // some logic
        }
      });
    }
  }
];

We can also use factories to get the same result

// app routing providers with factory
const appFactory = (router: Router) => () => {
   router.events.pipe(
    filter(event => event instanceof Scroll)
  ).subscribe({
    next: (e: Scroll) => {
      // some logic
    }
  });
};
export const AppRouteProviders = [
  importProvidersFrom(RouterModule.forRoot(AppRoutes, {
    scrollPositionRestoration: 'disabled'
  })),
  {
    // use environment initializer with factory
    provide: ENVIRONMENT_INITIALIZER,
    multi: true,
    useFactory: appFactory,
    deps: [Router]
  }
];

Note that this gets called immediately when added to main.ts, while the APP_INITIALIZER is programmed to be called when the application is ready, so the order of providers in main.ts does not affect APP_INITIALIZER, but it affects the environment initializer tokens.

Adding environment tokens to a lazy loaded route provider is also possible, and it is called when any route child is loaded.

// app routing providers with factory
const appFactory = (router: Router) => () => {
   router.events.pipe(
    filter(event => event instanceof Scroll)
  ).subscribe({
    next: (e: Scroll) => {
      // some logic
    }
  });
};
export const AppRouteProviders = [
  importProvidersFrom(RouterModule.forRoot(AppRoutes, {
    scrollPositionRestoration: 'disabled'
  })),
  {
		// use environment initializer with factory
    provide: ENVIRONMENT_INITIALIZER,
    multi: true,
    useFactory: appFactory,
    deps: [Router]
  }
];

This however is not related to the single child, but the whole route group, if the route is loaded, all providers’ tokens are initialized, in the order they appear in the group. Someone on GitHub proposed a tidier way to lazily initialize environment from the parent node, but until then, this is what we have.

Cannot provide PLATFORM_INITIALIZER

PLATFORM_INITIALIZER static provider does not get fired, instead, we can use ENVIRONMENT_INITIALIZER in the root bootstrapper.

Building and fixing

After building, I realized there is a huge lazy chunk with a very strange name coming out. I was appalled. There was no common.js either. Looking deeper, the main.js has dropped massively, and the change in size went into the first lazy chunk appearing in AppRoutes. This is not bad, but I realized that chunk is immediately loaded with all routes. It probably is wrong naming, it should be common.js.

Standalone bundle sizes

On server

There is nothing in documentation to insinuate any support for SSR, the closest thing I found on web was this repository hiepxanh/universatl-test where the author made adjustments to the ngExpressEngine and CommonEngine. Given the fact that it is not ready yet, I will not go down that path. Maybe one warm Tuesday evening.

Is it ready?

It is still in preview, and some things are ready, some are not. The issues of todays’ preview:

  • Lazy loaded bundle name is not common.js
  • CLI design-time does not report any errors, but there are a couple of issues logged in GitHub
  • SSR support is not out of the box, just yet.

If I want to use standalone today, I would for the following purposes

  • Library directives and pipes: I’m in, already
  • Shared components: Yes, yes, yes.
  • Feature common components: usually there is one or a couple shared components, and usually they are light.

I would avoid it for

  • Layout components: if you place your layout components in the application root, this will hardly make any difference given that all the required imports are already available on root. Light common layouts however can make use of the standalone feature.
  • App root, bootstrapping, and core modules: no immediate gain
  • Feature modules: more organized way to contain multiple components that have shared resources.

Thank you for reading all the way to the bottom. Did you smell any rotten tomatoes? Let me know.

  1. two months ago
    How to turn an Angular app into standalone - Part I
  2. one month ago
    How to turn an Angular app into standalone - Part II
  3. one month ago
    Angular standalone router providers: an update
  4. 11 days ago
    Angular 15 standalone HTTPClient provider: Another update