When building a web application, there are different aspects that we take into consideration, performance being one of them. Especially when working on a considerable Angular codebase, there is always space for performance improvements.
An inherited codebase is what I have been working on recently, and the main thing to be improved was the performance of the application — refactoring to libraries, smart and dumb components, and starting to utilize the OnPush
change detection strategy amongst other improvements.
In this article, I want to share the issue we faced while sparingly adding the OnPush
strategy to components. Additionally, I will elaborate on a few solutions that are already known that we can use, the latest one being the future, new reactive primitive, Angular Signals.
The OnPush Change Detection
Change Detection is the mechanism that makes sure that the application state is in sync with the UI. At a high level, Angular walks your components from top to bottom, looking for changes. The way it does, this is by comparing each current value of expressions in the template with the previous values they had using the strict equality comparison operator (===). This is known as dirty checking.
You can read more about in the official documentation.
Even though change detection is optimized and performant, in applications with large component trees, running change detection across the whole app too frequently may cause slowdowns and performance issues. This can be addressed by using the OnPush
change detection strategy, which tells Angular to never run the change detection for a component unless:
At the time the component is created.
The component is dirty.
There are actually 3 criteria when OnPush CD runs, and you can find more about them in this article.
This allows to skip change detection in an entire component subtree.
The Problem:
To better understand the issue, below is a small reproduction of the app supporting our case:
@Component({
selector: 'my-app',
standalone: true,
imports: [CommonModule, RouterLink, RouterOutlet],
template: `
<h1>OnPush & Signals</h1>
<a routerLink="/">Home</a>
<a routerLink="/products">Products </a>
<hr >
<router-outlet></router-outlet>
`,
})
export class AppComponent {
name = 'OnPush & Signals';
}
bootstrapApplication(App, {
providers: [
provideHttpClient(),
provideRouter([
{
path: '',
component: HomeComponent,
},
{
path: 'products',
loadComponent: () =>
import('./products-shell/products-shell.component'),
children: [
{
path: '',
loadChildren: () => import('./products').then((r) => r.routes),
},
],
},
]),
],
});
Using new standalone APIs in Angular, an application is bootstrapped with HttpClient and Router configured. The application has 2 routes configured, the default one for HomeComponent, and the 'products' for the Product feature (in our case being an Nx library) which is lazily-loaded when the route is activated and rendered in the lazily-loaded ProductShell component:
@Component({
selector: 'app-products-shell',
standalone: true,
imports: [RouterOutlet],
changeDetection: ChangeDetectionStrategy.OnPush, // configure OnPush
template: `
<header>
<h2>Products List</h2>
</header>
<router-outlet></router-outlet>
`,
styleUrls: ['./products-shell.component.css'],
})
export default class ProductsShellComponent { }
The Product feature itself has the following route configuration:
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./products.component'),
children: [
{
path: 'list',
loadComponent: () => import('./products-list/products-list.component'),
},
{
path: '',
redirectTo: 'list',
pathMatch: 'full',
},
],
},
];
Let’s first have a look at the way it should not be done, and then check the solutions:
Illustrating the problem
The ProductList component below calls the getProducts
function inside the ngOnInit
hook to get the list of products and then render it into a table.
@Component({
selector: 'app-products-list',
standalone: true,
imports: [NgFor],
template: `
<table>
<thead>
<tr>
<th>Title</th>
<th>Description</th>
<th>Price</th>
<th>Brand</th>
<th>Category</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let product of products">
<td>{{ product.title }}</td>
<td>{{ product.description }}</td>
<td>{{ product.price }}</td>
<td>{{ product.brand }}</td>
<td>{{ product.category }}</td>
</tr>
</tbody>
</table>
`,
styleUrls: ['./products-list.component.css'],
})
export default class ProductsListComponent implements OnInit {
products: Product[] =[];
productService = inject(ProductsService);
ngOnInit() {
this.productService.getProducts().subscribe((products) => {
this.products = products;
});
}
}
It will be rendered inside the Products component which wraps the <router-outlet>
inside a div
for page spacing purposes (in our case):
@Component({
selector: 'app-products',
standalone: true,
imports: [RouterOutlet],
template: `
<div class="main-content">
<router-outlet></router-outlet>
</div>
`,
styles: [`.main-content { margin-top: 15px }`],
})
export default class ProductsComponent {}
At first sight, this code seems correct, but no products will be rendered on the table, and no errors in the console.
What could be happening? 🤯
This happens because of ProductShell (root) component is being configured using OnPushchange detection, and the "imperative way" of retrieving the products list. The products list is retrieved successfully, and the data model is changed, marking the ProductsList component as dirty, but not its ancestor components. Marking ProductShell OnPush
skips all subtree of components from being checked for change unless it is marked dirty, hence data model change is not reflected on UI.
Now that we understand what the issue is, there are a few ways that can solve it. Of course, the easiest one is just reverting to the Default change detection and everything works. But let's see what are the other solutions out there:
Solution 1: Declarative Pattern with AsyncPipe
Instead of imperatively subscribing to the getProducts
function in the component, we subscribe to it in the template by using the async
pipe:
@Component({
...
template: `
<table>
...
<tbody>
<tr *ngFor="let product of products$ | async">
<td>{{ product.title }}</td>
<td>{{ product.description }}</td>
<td>{{ product.price }}</td>
<td>{{ product.brand }}</td>
<td>{{ product.category }}</td>
</tr>
</tbody>
</table>
`
})
export default class ProductsListComponent {
productService = inject(ProductsService);
products$ = this.productService.getProducts();
}
The async
pipe automatically subscribes to the observable returned by the getProducts
function and returns the latest value it has emitted. When a new value is emitted, it marks the component to be checked for changes, including ancestor components (ProductShell is one of them in this case). Now, Angular will check for changes ProductShell component together with its component tree including the ProductList component, and thus UI will be updated with products rendered on a table:
Solution 2: Using Angular Signals 🚦
Signals
, introduced in Angular v16 in the developer preview, represent a new reactivity model that tells the Angular about which data the UI cares about, and when that data changes thus easily keeping UI and data changes in sync. Together with the future Signal-Based components, will make possible fine-grained reactivity and change detection in Angular.
You can read more about
Signals
in the official documentation.
In its basics, a signal
is a wrapper around a value that can notify interested consumers when that value changes. In this case, the ‘products’ data model will be a signal of the products which will be bound directly to the template and thus be tracked by Angular as that component’s dependency:
@Component({
...
template: `
<table>
...
<tbody> <!-- getter function: read the signal value-->
<tr *ngFor="let product of products()">
<td>{{ product.title }}</td>
<td>{{ product.description }}</td>
<td>{{ product.price }}</td>
<td>{{ product.brand }}</td>
<td>{{ product.category }}</td>
</tr>
</tbody>
</table>
`
})
export default class ProductsListComponent implements OnInit {
products = signal<Product[]>([]);
productService = inject(ProductsService);
ngOnInit() {
this.productService.getProducts().subscribe((products) => {
this.products.set(products);
});
}
}
When the 'products' signal gets a new value (through the setter function), being read directly on the template (through a getter function), Angular detects changed bindings, marking the ProductList component and all its ancestors' components as dirty / for change on the next change detection cycle.
Then, Angular will check for changes ProductShell component together with its component tree including the ProductList component, and thus UI will be updated with products rendered on a table:
The same solution can be achieved by following a declarative approach using the toSignal
function:
@Component({
selector: 'app-products-list',
standalone: true,
imports: [NgFor, AsyncPipe],
template: `
<table>
…
<tr *ngFor="let product of products()">
<td>{{ product.title }}</td>
<td>{{ product.description }}</td>
<td>{{ product.price }}</td>
<td>{{ product.brand }}</td>
<td>{{ product.category }}</td>
</tr>
…
</table>
`
})
export default class ProductsListComponent implements OnInit {
productService = inject(ProductsService);
products: Signal<Product[]> = toSignal(this.productService.getProducts(), {
initialValue: [],
});
}
toSignal
is a utility function provided by @angular/core.rxjs-interop (in developer preview) package to integrate signals with RxJs observables. It creates a signal which tracks the value of an Observable. It behaves similarly to the async
pipe in templates, it marks the ProductList component and all its ancestors for change / dirty thus UI will be updated accordingly.
You can find and play with the final code here: https://stackblitz.com/edit/onpush-cd-deep-route?file=src/main.ts 🎮
Special thanks to @kreuzerk, @eneajaho, and @danielglejzner for review.
Thanks for reading!
This is my first article, and I hope you enjoyed it 🙌.
For any questions or suggestions, feel free to leave a comment below 👇.
If this article is interesting and useful to you, and don’t want to miss future articles, give me a follow at @lilbeqiri, Medium or dev.to. 📖
Top comments (7)
Thanks for the article! It is very interesting!
I would like to comment a doubt, what if you remove onPush and apply the solution with signals? Will it re-render the whole app subtrees (not only the product one) or will we gain some benefits of it?
Thanks for your kind words!
Up to this version of Angular, signals, represent the future, reactive way of detecting changes but still the change in their value, will be captured by zonejs, and thus trigger change detection. Their real power comes into play when signal-based components are introduced.
Removing OnPush, even if we use Signals, Angular is going to walk all components from top to bottom, looking for changes, in this case, whole app subtrees. It's the OnPush that tells Angular if a subtree of components needs to be checked for changes based on 3 criteria.
Hey, great article @ilirbeqirii. 2 more cents.
You have the difference even right now. With OnPush + Signals, the Local change detection is possible. It is different compared to standard OnPush.
In that case, Angular does not check every template in the views tree. It checks only those where signals were changed.
Here is a post where I explain that (not really in details): twitter.com/sharikov_vlad/status/1...
I have a draft of an article explaining all of the above in detail. Stay tuned :)
Yeah article was published before what we have now.
And btw I read all of your recent articles related to CD and Signals and Effects, and loved them ❤️
Alright, thanks for your clarification!!
Solution 3:
Call detectChanges function on ChangeDetectorRef.
.pipe(finalize(() => (this.cdr.detectChanges())))
.subscribe(...
Solution 4:
Bind output from ProductsListComponent (you can use EventEmitter, Subject, etc.) to parent and receive it in the selector of ProductsListComponent in parent. (you dont have to handle the $event. Just receive it in the selector and it will trigger change detection.)
Very interesting, thank you