Routing and Lazy Loading with Angular’s Standalone Components

For lazy loading, we can directly point to further routing configurations but also to Standalone Components.

  1. Angular’s Future Without NgModules – Part 1: Lightweight Solutions Using Standalone Components
  2. Angular’s Future Without NgModules – Part 2: What Does That Mean for Our Architecture?
  3. 4 Ways to Prepare for Angular’s Upcoming Standalone Components
  4. Routing and Lazy Loading with Angular’s Standalone Components
  5. Angular Elements: Web Components with Standalone Components
  6. The Refurbished HttpClient in Angular 15 – Standalone APIs and Functional Interceptors
  7. Testing Angular Standalone Components
  8. Automatic Migration to Standalone Components in 3 Steps

Update on 2022-10-10: Updated for Angular 14.2.0.

Since its first days, the Angular Router has always been
quite coupled to NgModules. Hence, one question that comes up when moving to Standalone Components is: How will routing and lazy loading work without NgModules? This article provides answers and also shows, why the router will become more important for Dependency Injection.

Big thanks to Pawel Kozlowski from the Angular team for proofreading this article!

The 📂 source code for the examples used here can be found in the form of a traditional Angular CLI workspace and as an Nx workspace that uses libraries as a replacement for NgModules.

Providing the Routing Configuration

When bootstrapping a standalone component, we can provide services for the root scope. These are services you used to provide in your AppModule. Meanwhile, the Router provides a function provideRouter that returns all providers we need to register here:

// main.ts

import { importProvidersFrom } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { 
    PreloadAllModules, 
    provideRouter, 
    withDebugTracing, 
    withPreloading, 
    withRouterConfig 
} 
from '@angular/router';

import { APP_ROUTES } from './app/app.routes';
[...]

bootstrapApplication(AppComponent, {
  providers: [
    importProvidersFrom(HttpClientModule),
    provideRouter(APP_ROUTES, 
      withPreloading(PreloadAllModules),
      withDebugTracing(),
    ),

    [...]

    importProvidersFrom(TicketsModule),
    provideAnimations(),
    importProvidersFrom(LayoutModule),
  ]
});

The function provideRouter not only takes the root routes but also the implementation of additional router features. These features are passed with functions having the naming pattern withXYZ, e. g. withPreloading or withDebugTracing. As functions can easily be tree-shaken, this design decisions makes the whole router more tree-shakable.

With the discussed functions, the Angular team also introduces a naming pattern, library authors should follow. Hence, when adding a new library, we just need to look out for an provideXYZ and for some optional withXYZ functions.

As currently not every library comes with a provideXYZ function yet, Angular comes with the bridging function importProvidersFrom. It allows to get hold of all the providers defined in existing NgModules and hence is the key for using them with Standalone Components.

I'm quite sure, the usage of importProvidersFrom will peak off over time, as more and more libraries will provide functions for directly configuring their providers. For instance, NGRX recently introduced a provideStore and a provideEffects function.

Using Router Directives

After setting up the routes, we also need to define a placeholder where the Router displays the activated component and links for switching between them. To get the directives needed for this, you might directly import the RouterModule into your Standalone Component. However, a better alternative is to just import the directives you need:

@Component({
  standalone: true,
  selector: 'app-root',
  imports: [
    // Just import the RouterModule:
    // RouterModule,

    // Better: Just import what you need:
    RouterOutlet,
    RouterLinkWithHref,

    NavbarComponent,
    SidebarComponent,
  ],
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
    [...]
}

Just importing the actually needed directives is possible, because the router exposed them as Standalone Directives. Please note that RouterLinkWithHref is needed if you use routerLink with an a-tag; in all other cases you should import RouterLink instead. While this might sound confusing, this is nothing we need to worry about in the mid-term when IDEs start providing auto-imports for Standalone Components.

Lazy Loading with Standalone Components

In the past, a lazy route pointed to an NgModule with child routes. As there are no NgModules anymore, loadChildren can now directly point to a lazy routing configuration:

// app.routes.ts

import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';

export const APP_ROUTES: Routes = [
    {
        path: '',
        pathMatch: 'full',
        redirectTo: 'home'
    },
    {
        path: 'home',
        component: HomeComponent
    },

    // Option 1: Lazy Loading another Routing Config
    {
        path: 'flight-booking',
        loadChildren: () =>
            import('./booking/flight-booking.routes')
                .then(m => m.FLIGHT_BOOKING_ROUTES)
    },

    // Option 2: Directly Lazy Loading a Standalone Component
    {
        path: 'next-flight',
        loadComponent: () => 
            import('./next-flight/next-flight.component')
                .then(m => m.NextFlightComponent)
    },
    [...]
];

This removes the indirection via an NgModule and makes our code more explicit. As an alternative, a lazy route can also directly point to a Standalone Component. For this, the above shown loadComponent property is used.

I expect that most teams will favor the first option, because normally, an application needs to lazy loading several routes that go together.

Environment Injectors: Services for Specific Routes

With NgModules, each lazy module introduced a new injector and hence a new injection scope. This scope was used for providing services only needed by the respective lazy chunk.

To cover such use cases, the Router now allows for introducing providers for each route. These services can be used by the route in question and their child routes:

// booking/flight-booking.routes.ts

export const FLIGHT_BOOKING_ROUTES: Routes = [{
    path: '',
    component: FlightBookingComponent,
    providers: [
        provideBookingDomain(config)
    ],
    children: [
        {
            path: '',
            pathMatch: 'full',
            redirectTo: 'flight-search'
        },
        {
            path: 'flight-search',
            component: FlightSearchComponent
        },
        {
            path: 'passenger-search',
            component: PassengerSearchComponent
        },
        {
            path: 'flight-edit/:id',
            component: FlightEditComponent
        }
    ]
}];

As shown here, we can provide services for several routes by grouping them as child routes. In these cases, a component-less parent route with an empty path (path: '') is used. This pattern is already used for years to assign Guards to a group of routes.

Technically, using adding a providers array to a router configuration introduces a new injector at the level of the route. Such an injector is called Environment Injector and replaces the concept of the former (Ng)Module Injectors. The root injector and the platform injector are further Environment Injectors.

Interestingly, this also decouples lazy loading from introducing further injection scopes. Previously, each lazy NgModule introduced a new injection scope, while non-lazy NgModules never did. Now, lazy loading itself doesn't influence the scopes. Instead, now, you define new scopes by adding a providers array to your routes, regardless if the route is lazy or not.

The Angular team recommends to use this providers array with caution and to favor providedIn: 'root' instead. As already mentioned in a previous article of this series, also providedIn: 'root' allows for lazy loading. If you just use a services provided with providedIn: 'root' in lazy parts of your application, they will only be loaded together with them.

However, there is one situation where providedIn: 'root' does not work and hence the providers array shown is needed, namely if you need to pass a configuration to a library. I've already indicated this in the above example by passing a config object to my custom provideBookingDomain. The next section provides a more elaborated example for this using NGRX.

Setting up NGRX and Feature Slices

To illustrate how to use libraries adopted for Standalone Components with lazy loading, let's see how to setup NGRX. Let's start with providing the needed global services:

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

import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';

import { reducer } from './app/+state';

[...]

bootstrapApplication(AppComponent, {
  providers: [
    importProvidersFrom(HttpClientModule),
    provideRouter(APP_ROUTES, 
      withPreloading(PreloadAllModules),
      withDebugTracing(),
    ),

    // Setup NGRX:
    provideStore(reducer),
    provideEffects([]),
    provideStoreDevtools(),

    importProvidersFrom(TicketsModule),
    provideAnimations(),
    importProvidersFrom(LayoutModule),
  ]
});

For this, we go with the functions provideStore, provideEffects, and provideStoreDevtools NGRX comes with since version 14.3.

To allow lazy parts of the application to have their own feature slices, we call provideState and provideEffects in the respective routing configuration:

import { provideEffects } from "@ngrx/effects";
import { provideState } from "@ngrx/store";

export const FLIGHT_BOOKING_ROUTES: Routes = [{
    path: '',
    component: FlightBookingComponent,
    providers: [
        provideState(bookingFeature),
        provideEffects([BookingEffects])
    ],
    children: [
        {
            path: 'flight-search',
            component: FlightSearchComponent
        },
        {
            path: 'passenger-search',
            component: PassengerSearchComponent
        },
        {
            path: 'flight-edit/:id',
            component: FlightEditComponent
        }
    ]
}];

While provideStore sets up the store at root level, provideState sets up additional feature slices. For this, you can provide a feature or just a branch name with a reducer. Interestingly, the function provideEffects is used at the root level but also at the level of lazy parts. Hence, it provides the initial effects but also effects needed for a given feature slice.

Setting up Your Environment: ENVIRONMENT_INITIALIZER

Some libraries used the constructor of lazy NgModule for their initialization. To further support this approach without NgModules, there is now the concept of an ENVIRONMENT_INITIALIZER:

export const FLIGHT_BOOKING_ROUTES: Routes = [{
    path: '',
    component: FlightBookingComponent,
    providers: [
        importProvidersFrom(StoreModule.forFeature(bookingFeature)),
        importProvidersFrom(EffectsModule.forFeature([BookingEffects])),
        {
            provide: ENVIRONMENT_INITIALIZER,
            multi: true,
            useValue: () => inject(InitService).init()
        }
    ],
    children: [
        [...]
    ]
}

Basically, the ENVIRONMENT_INITIALIZER provides a function executed when the Environment Injector is initialized. The flag multi: true already indicates that you can have several such initializers per scope.

What's next? More on Architecture!

So far, we've seen how to decompose a huge client into several libraries with Standalone Components. However, when dealing with enterprise-scale frontends, several additional questions come in mind:

  • According to which criteria can we subdivide a huge application into sub-domains?
  • How can we make sure, the solution is maintainable for years or even decades?
  • Which options from Micro Frontends are provided by Module Federation?

Our free eBook (about 120 pages) covers all these questions and more:

free ebook

Feel free to download it here now!