Should You Pair Signals & OnPush?

Leverage the power of OnPush Components with Signals

Ilir Beqiri
7 min readSep 21, 2023
The blog post header image is about the optimization of Angular apps with signals and onpush change detection.

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> &nbsp;
<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.

Navigating from home page to the product list page when no data is rendered because of the change detection issue

What could be happening? 🤯

This happens because of ProductShell (root) component is being configured using OnPush change 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:

Navigating from home page to the product’s list page, we get the list of products rendered on the 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:

Navigating from home page to the product’s list page, we get the list of products rendered on the 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 {
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 Kevin Kreuzer, Enea Jahollari and Daniel Glejzner for review.

Thanks for reading!

This is my first article on Medium, 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 or Medium. 📖

--

--

Ilir Beqiri

👨‍💻Front End Engineer 😵‍💫Passionate About JS, TS, Angular 🅰️ ✍️ Sharing Knowledge with Community