@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),
          },
        ],
      },
    ]),
  ],
});
@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 { }
export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('./products.component'),
    children: [
      {
        path: 'list',
        loadComponent: () => import('./products-list/products-list.component'),
      },
      {
        path: '',
        redirectTo: 'list',
        pathMatch: 'full',
      },
    ],
  },
];

Understanding the OnPush Change Detection Strategy

When developing a web application, performance frequently ranks high among the priorities we evaluate. This is especially true when dealing with a substantial Angular project, where there is typically ample opportunity for optimization.

Recently, I inherited a codebase where the primary focus was improving application performance. Our efforts included restructuring into libraries, separating smart and dumb components, and introducing the OnPush change detection strategy, among other enhancements.

The purpose of this discussion is to highlight a problem we encountered when selectively applying OnPush to certain components. I will also cover several established solutions to address this issue, with the most recent approach being Angular Signals—a new reactive primitive on the horizon.

Change detection is the internal mechanism responsible for keeping the application state synchronized with what the user sees. In simple terms, Angular traverses the component tree from the root downwards, inspecting for any modifications. This inspection involves comparing each template expression's current value against its previous value using the strict equality operator (===). This process is commonly referred to as dirty checking.

The official documentation provides additional details on this topic.

Although change detection is optimized for performance, large component trees can experience slowdowns if change detection runs too frequently across the entire application. The OnPush strategy offers a solution by instructing Angular to bypass change detection for a component unless specific conditions are met:

  • When the component is initially created.

  • When the component is flagged as dirty.

Note that there are actually three conditions under which OnPush change detection executes—you can explore all of them in this article.

Implementing this strategy enables Angular to skip change detection for an entire component subtree, which can significantly improve performance.

The Challenge:

To illustrate the issue we encountered, consider the following simplified reproduction of our application:

The application is bootstrapped using the modern standalone APIs, with HttpClient and Router already configured. It defines two routes: the default route for the HomeComponent, and a 'products' route for the Product feature. This feature resides in an Nx library and is loaded lazily when the route is accessed, rendering within the lazily-loaded ProductShell component.

Within the Product feature, the routing is structured as follows:

Let me first demonstrate the incorrect approach before examining the viable solutions.

Demonstrating the Issue

The ProductList component shown below invokes the getProducts method within the ngOnInit lifecycle hook to fetch the product data and display it in a table format.

@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;
    });
  }
}

This component is nested inside the Products component, which contains a <router-outlet> wrapped in a div—in our case, adding spacing for the page layout:

@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 a glance, the implementation appears sound, yet the table remains empty without any console errors.

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

What's going on here? 🤯

The root cause lies in the ProductShell component being configured with OnPush change detection while the product list is retrieved using the imperative pattern. The data is fetched successfully, and the model is updated, which marks the ProductsList component as dirty—but not any of its parent components. Because ProductShell uses OnPush, the entire component subtree is skipped during change detection unless explicitly marked dirty, so the updated data never reaches the template.

Now that the problem is clear, several approaches can resolve it. The quickest fix is reverting to the Default change detection strategy, but let's explore other options:

Option 1: Declarative Approach with AsyncPipe

Rather than subscribing to getProducts imperatively inside the component, we can handle the subscription in the template 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 handles the subscription to the observable produced by getProducts automatically, returning the most recent emitted value. On each new emission, the pipe triggers a change detection check for the component and its parent chain—including ProductShell. Angular then performs a full check on ProductShell and its entire component tree, including ProductList, which ensures the table updates with the fetched products:

Navigating from home page to the product's list page, we get the list of products rendered on the table.

Option 2: Leveraging Angular Signals 🚦

Signals, introduced in Angular v16 as a developer preview, introduce a fresh reactivity model. They inform Angular about which data the UI depends on, allowing the framework to keep the view and data in sync effortlessly. When paired with the upcoming Signal-Based components, they enable granular reactivity and more efficient change detection.

For deeper insights on Signals, consult the official documentation.

At its core, a signal wraps a value and notifies consumers whenever that value changes. Here, the 'products' data model becomes a signal that is directly referenced in the template, making it a tracked dependency of the component:

@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 a new value is assigned to the 'products' signal (via its setter), and it's read in the template (through the getter), Angular recognizes the binding change. This flags the ProductList component and all its ancestor components as dirty, scheduling them for checking in the next change detection cycle.

Angular then traverses the component tree from ProductShell downward, including ProductList, ensuring the table displays the products correctly:

Navigating from the home page to the product's list page, we get the list of products rendered on the table.

An equivalent outcome can be achieved declaratively 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: [],
  });
}

The toSignal helper, available from @angular/core.rxjs-interop (also in developer preview), bridges signals with RxJS observables. It produces a signal that mirrors an Observable's emissions. Similar to the async pipe in templates, it marks the ProductList component and its ancestors as dirty, prompting the UI to refresh.

Feel free to experiment with the complete code here: https://stackblitz.com/edit/onpush-cd-deep-route?file=src/main.ts 🎮

Appreciation goes to @kreuzerk, @eneajaho, and @danielglejzner for their feedback.

Thank you for reading!

This is my inaugural post, and I trust you found it valuable 🙌.

For any inquiries or feedback, don't hesitate to comment below 👇.

If you found this article helpful and want to stay updated, follow me on @lilbeqiri, Medium, or dev.to. 📖