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
anchorGetting 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;
anchorLazy 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.
anchorOur 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.
anchorGetting 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.
anchorThe 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?
anchorTear 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 withoutRouterModule
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.
anchorDebloating 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.
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,
],
});
anchorThe 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.
anchorCannot provide PLATFORM_INITIALIZER
PLATFORM_INITIALIZER
static provider does not get fired, instead, we can use ENVIRONMENT_INITIALIZER
in the root bootstrapper.
anchorBuilding 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
.
anchorOn 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.
anchorIs 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.