DEV Community

Cover image for How to turn an Angular app into standalone - Part II
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

How to turn an Angular app into standalone - Part II

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 {}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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,
  },
];
Enter fullscreen mode Exit fullscreen mode

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
      ),
  },
];
Enter fullscreen mode Exit fullscreen mode

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,
  }
];
Enter fullscreen mode Exit fullscreen mode

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 {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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 {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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],
})
// ...
Enter fullscreen mode Exit fullscreen mode

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),
},
Enter fullscreen mode Exit fullscreen mode

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),
},
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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

// 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)),
  ],
});
Enter fullscreen mode Exit fullscreen mode

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 {
// ...
}
Enter fullscreen mode Exit fullscreen mode

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)],
  },
  // ...
];
Enter fullscreen mode Exit fullscreen mode

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...
  ],
});
Enter fullscreen mode Exit fullscreen mode

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,
  ],
});
Enter fullscreen mode Exit fullscreen mode

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
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
      });
    }
  }
];
Enter fullscreen mode Exit fullscreen mode

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]
  }
];
Enter fullscreen mode Exit fullscreen mode

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]
  }
];
Enter fullscreen mode Exit fullscreen mode

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.

RESOURCES

Oldest comments (0)