DEV Community

Cover image for Should You Pair Signals & OnPush?
Ilir Beqiri for This is Angular

Posted on

Should You Pair Signals & OnPush?

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

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

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

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

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

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

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

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 the 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 {
  productService = inject(ProductsService);
  products: Signal<Product[]> = toSignal(this.productService.getProducts(), {
    initialValue: [],
  });
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
alejandrocuevas profile image
AlejandroCuevas

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?

Collapse
 
lilbeqiri profile image
Ilir Beqiri • Edited

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.

Collapse
 
s_v profile image
Sharikov Vladislav

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 :)

Thread Thread
 
lilbeqiri profile image
Ilir Beqiri

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 ❤️

Collapse
 
alejandrocuevas profile image
AlejandroCuevas

Alright, thanks for your clarification!!

Collapse
 
dev34 profile image
Dev • Edited

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.)

Image description

Collapse
 
alaindet profile image
Alain D'Ettorre

Very interesting, thank you