Photo by Sammy Wong on Unsplash
Speed up rendering in your Angular application
Techniques to speed up your Angular apps by reducing the number of renders
After working with Angular for quite some time, I have come to love how you can build modular, scalable applications.
The road to using this framework effectively can be quite a bumpy one though, or at least I know it was for me! As stakeholders started to use my first app and requirements came in to add features, my code started to get increasingly messy, and the UI got pretty sluggish, pretty fast.
Luckily, the web abounds with resources and the Angular team worked overtime to give us the tools we need to make things better. In this article, I will present you with some concepts that are sure to help you build faster apps AND improve their architecture.
Please note that all the info I mention here assumes that we are working with Angular 14.
Lazy loading Feature Modules
Here is a beginner tip, yet one that is worth remarking: using your Angular modules properly can decrease the initial loading time of your app dramatically thanks to lazy loading. If you come from a different background, you might realize soon enough that you can mostly ignore NgModules when building your application and create a plain component tree; this would not be a smart choice, though. As modules are one of the most defining and useful features of this framework, not taking the time to understand them would kill the purpose of using Angular for your project.
Let's say we are building an app that includes a couple of sections, one for managing users and one for browsing the history of orders. I know this is very simplistic, but please bear with me. The Angular way of doing this would consist of creating two Feature Modules, UsersModule and OrdersModule. A Feature Module (or Domain Module, according to the official Angular docs) is an NgModule that is organized around a feature, business domain, or user experience. By using these we can split concerns throughout our application, thus improving its architecture, and this is a very neat achievement per se.
Now, let's configure some routes in the AppRoutingModule and see how it is going to affect performance. A very old way of doing this would be the following
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { OrdersModule } from './features/orders/orders.module';
import { UsersModule } from './features/users/users.module';
import { HomeComponent } from './home/home.component';
const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'users',
loadChildren: () => UsersModule
},
{
path: 'orders',
loadChildren: () => OrdersModule
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
We will not settle for this solution even if it works, and here is why. As we import OrdersModule and UsersModule, Webpack is going to bundle them with AppModule, which means that when the user browses OrdersModule, it is not going to be loaded until UsersModule is also available and vice versa. Worse still, when the user browses the home page they are going to download two modules they don't need yet before they get access to the one they need, which is AppModule.
We can avoid this by using a different syntax:
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'users',
loadChildren: () => import('./features/users/users.module').then(m => m.UsersModule)
},
{
path: 'orders',
loadChildren: () => import('./features/orders/orders.module').then(m => m.OrdersModule)
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
This way UsersModule and OrdersModule each get bundled in their own file and get lazily loaded when the user browses the proper route. Imagine the impact this could have on a real-world, large-scale application startup. On the other hand, as the user browses the application they will experiment some lag due to the relevant chunks getting downloaded. Well, we can do even better!
@NgModule({
imports: [RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })],
exports: [RouterModule]
})
export class AppRoutingModule { }
By adding { preloadingStrategy: PreloadAllModules }
, we change the loading behavior to this:
- The user browses a route
- The browser downloads the relevant chunk along with the main one (the one which contains AppModule)
- The UI is loaded and the user can start using the web app
- In the background, the other chunks get downloaded
- When the user browses a different route the relevant chunk is already loaded, making for a smooth navigation
Nice! We now know everything we need to speed up the initial rendering while separating concerns in our application at a high level. I highly recommend spending some time looking up other commonly used NgModule patterns you can use to split concerns even further, such as the usage of Shared Modules.
This does not improve rendering performance inside our Feature Modules at all though, can we do something about that?
Enter ChangeDetectionStrategy.OnPush
In most cases, the key as to why Angular apps slow down as they get bigger resides in how many times each component is rendered, that is in which cases the framework detects changes that are relevant to each component (thus re-rendering it).
In fact, by default, an Angular component gets re-rendered in a variety of cases most of which are not actually useful. A simple yet instructive way to observe this would be to pick a component, preferably one that is nested in a complex hierarchy, write an onRender method that just performs a console.log, and add {{ onRender() }}
to the template to have an idea of how many times it is rendered. You will have a fair amount of Why is it getting re-rendered again? moments, even while interacting with different components!
We can change this by using the OnPush change detection strategy for our component:
@Component({
selector: 'app-users-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ng-container *ngIf="users">
<app-user-card *ngFor="let user of users" [user]="user"></app-user-card>
</ng-container>
`
})
export class UsersListComponent implements OnInit {
users?: UserApi[];
constructor(private usersService: UsersService) { }
ngOnInit(): void {
this.usersService.getUsers().subscribe(users => this.users = users);
}
}
This adjustment will dramatically decrease the number of renders in complex apps, but while experimenting with it you will notice that it does not display everything you expect; this would be the case if, as in the snippet above, it fetches data through a service and then it is supposed to show it. Why is that? OnPush changes the behavior for the component to make it re-render only when one of its Inputs changes. Therefore, you should only use OnPush in components that behave like pure functions, that is if they only depend on their Inputs and not on any other external factor (e.g.: an XHR call, an attribute inside a service).
@Component({
selector: 'app-users-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-user-card *ngFor="let user of users" [user]="user"></app-user-card>
`
})
export class UsersListComponent {
@Input() users!: UserApi[];
}
@Component({
selector: 'app-users',
template: `
<app-users-list *ngIf="(users$ | async) as users" [users]="users"></app-users-list>
`
})
export class UsersComponent {
users$ = this.usersService.getUsers();
constructor(private usersService: UsersService) { }
}
Container and Presentational Components
The two snippets above introduce us to a very useful pattern for component-based applications, that is Container and Presentational Components a.k.a. Smart and Dumb Components
. This pattern is especially useful when using a state manager, but it remains a godsend even when that's not the case.
Container components, such as UsersComponent, are allowed to contain side effects along with state and logic that belongs to the business domain. If you are using NgRx or another state manager of your choice, then the responsibility of a Container component is to make data of interest from the store available through selectors and to map Output events from Presentational components into action dispatches. We cannot use OnPush here! We usually keep Container components in the containers/ subfolder of our feature modules.
Presentational components, such as UsersListComponent, have a state which only comprises their Inputs and, if needed, some UI state (e.g.: is this dropdown open?); they pass on user interactions to their parent components through Outputs. Injecting services is not recommended unless they have nothing to do with business logic. This is strictly when you should use ChangeDetectionStrategy.OnPush, as there is no downside at all. Sometimes you will still need to trigger change detection for very particular reasons; in those cases, you will want to trigger it explicitly with a ChangeDetectorRef instance. We usually keep Presentational components in the components/ subfolder of our feature modules. As they are stateless from a business logic perspective, Presentational components are easily reusable and much easier to unit test than your classic, obscure, spaghetti component.
Methods and Getters in the template
If you have been using methods and getters in your bindings, you might want to reconsider that as it does slow down your app as opposed to other frameworks. As Angular can only render a component template in its entirety, all bindings are evaluated at each render, which prompts all related methods and getters to be called even if they have not changed. This can be improved, of course.
The most obvious way to work around with issue is to replace these getters and methods with additional variables which only get reassigned when they actually change. This can happen in a variety of cases, such as at the end of an HTTP request, after another variable changes, or, most common for Presentational Components, as an Input changes via the onChanges lifecycle hook. This sounds pretty tedious, is there another way?
Sure, we can use pipes.
@Pipe({
name: 'userDescription'
})
export class UserDescriptionPipe implements PipeTransform {
transform(user: UserApi): string {
return `${user.firstName} ${user.lastName}`;
}
}
This pretty much looks like using a method in the component class, but it's going to perform better because pipes are pure by default, which means that they are only going to be re-evaluated when their inputs change. Plus, it is reusable!
There is a caveat, though. Consider this example:
@Pipe({
name: 'userDescription'
})
export class UserDescriptionPipe implements PipeTransform {
transform(user: UserApi): string {
return `${user.firstName} ${user.lastName} - ${user.address.zipCode}`;
}
}
The issue is that pure pipes do not get re-rendered if a non-primitive attribute inside one of its parameters changes (in this case, that would be user.address
). To account for this, we can either:
- set
pure: false
in the Pipe decorator to make it react to impure changes. This would make it less efficient - treat the parameters as immutable objects, that is reassign the object instead of mutating it, e.g. with
this.user = { ...user, address: newAddress }
as opposed tothis.user.address = newAddress
. By doing this we can keep the pipe pure; besides, we probably want to do this anyway because of how Input change detection works.
Conclusions
Some of the techniques are pretty quick to implement, while others bring you to review your app architecture. That, along with how each one affects a different aspect of Angular app slowdowns should hopefully make it helpful to most developers who need a performance boost for their app. If you liked this article, especially the OnPush part, you might want to give NgRx a chance next. While it does not improve performance directly, it fits the architecture I described perfectly and it will make your app structure more predictable.
About Us
This article is part of the Mònade Developers Blog, written by Andrea Massioli.