Optimize Angular's change detection

Implement faster change detection for better user experience.

Angular runs its change detection mechanism periodically so that changes to the data model are reflected in an app's view. Change detection can be triggered either manually or through an asynchronous event (for example, a user interaction or an XHR completion).

Change detection is a powerful tool, but if it's run very often, it can trigger a lot of computations and block the main browser thread.

In this post you'll learn how to control and optimize the change detection mechanism by skipping parts of your application and running change detection only when necessary.

Inside Angular's change detection

To understand how Angular's change detection works, let's look at a sample app!

You can find the code for the app in this GitHub repository.

The app lists employees from two departments in a company—sales and R&D—and has two components:

  • AppComponent, which is the root component of the app, and
  • Two instances of EmployeeListComponent, one for sales and one for R&D.

Sample application

You can see the two instances of EmployeeListComponent in the template for AppComponent:

<app-employee-list
  [data]="salesList"
  department="Sales"
  (add)="add(salesList, $event)"
  (remove)="remove(salesList, $event)"
></app-employee-list>

<app-employee-list
  [data]="rndList"
  department="R&D"
  (add)="add(rndList, $event)"
  (remove)="remove(rndList, $event)"
></app-employee-list>

For each employee there's a name and a numeric value. The app passes the employee's numeric value to a business calculation and visualizes the result on screen.

Now take a look at EmployeeListComponent:

const fibonacci = (num: number): number => {
  if (num === 1 || num === 2) {
    return 1;
  }
  return fibonacci(num - 1) + fibonacci(num - 2);
};

@Component(...)
export class EmployeeListComponent {
  @Input() data: EmployeeData[];
  @Input() department: string;
  @Output() remove = new EventEmitter<EmployeeData>();
  @Output() add = new EventEmitter<string>();

  label: string;

  handleKey(event: any) {
    if (event.keyCode === 13) {
      this.add.emit(this.label);
      this.label = '';
    }
  }

  calculate(num: number) {
    return fibonacci(num);
  }
}

EmployeeListComponent accepts a list of employees and a department name as inputs. When the user tries to remove or add an employee, the component triggers a corresponding output. The component also defines the calculate method, which implements the business calculation.

Here's the template for EmployeeListComponent:

<h1 title="Department">{{ department }}</h1>
<mat-form-field>
  <input placeholder="Enter name here" matInput type="text" [(ngModel)]="label" (keydown)="handleKey($event)">
</mat-form-field>
<mat-list>
  <mat-list-item *ngFor="let item of data">
    <h3 matLine title="Name">
      {{ item.label }}
    </h3>
    <md-chip title="Score" class="mat-chip mat-primary mat-chip-selected" color="primary" selected="true">
      {{ calculate(item.num) }}
    </md-chip>
  </mat-list-item>
</mat-list>

This code iterates over all the employees in the list and, for each one, renders a list item. It also includes an ngModel directive for two-way data binding between the input and the label property declared in EmployeeListComponent.

With the two instances of EmployeeListComponent, the app forms the following component tree:

Component tree

AppComponent is the root component of the application. Its child components are the two instances of EmployeeListComponent. Each instance has a list of items (E1, E2, etc.) that represent the individual employees in the department.

When the user begins entering the name of a new employee in the input box in anEmployeeListComponent, Angular triggers change detection for the entire component tree starting from AppComponent. This means that while the user is typing in the text input, Angular is repeatedly recalculating the numeric values associated with each employee to verify that they haven't changed since the last check.

To see how slow this can be, open the non-optimized version of the project on StackBlitz and try entering an employee name.

You can verify that the slowdown comes from the fibonacci function by setting up the example project and opening the Performance tab of Chrome DevTools.

  1. Press `Control+Shift+J` (or `Command+Option+J` on Mac) to open DevTools.
  2. Click the Performance tab.

Now click Record (in the top-left corner of the Performance panel) and start typing in one of the text boxes in the app. In a few seconds, click Record again to stop recording. Once Chrome DevTools processes all the profiling data it collected, you'll see something like this:

Performance profiling

If there are many employees in the list, this process may block the browser's UI thread and cause frame drops, which leads to a bad user experience.

Skipping component subtrees

When the user is typing in the text input for the sales EmployeeListComponent you know that the data in the R&D department isn't changing—so there's no reason to run change detection on its component. To make sure the R&D instance doesn't trigger change detection, set the changeDetectionStrategy of EmployeeListComponent to OnPush:

import { ChangeDetectionStrategy, ... } from '@angular/core';

@Component({
  selector: 'app-employee-list',
  template: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrls: ['employee-list.component.css']
})
export class EmployeeListComponent {...}

Now when the user types in a text input, change detection is only triggered for the corresponding department:

Change detection in a component subtree

You can find this optimization applied to the original application here.

You can read more about the OnPush change detection strategy in the official Angular documentation.

To see the effect of this optimization, enter a new employee in the application on StackBlitz.

Using pure pipes

Even though the change detection strategy for the EmployeeListComponent is now set to OnPush, Angular still recalculates the numeric value for all employees in a department when the user types in the corresponding text input.

To improve this behavior you can take advantage of pure pipes. Both pure and impure pipes accept inputs and return results that can be used in a template. The difference between the two is that a pure pipe will recalculate its result only if it receives a different input from its previous invocation.

Remember that the app calculates a value to display based on the employee's numeric value, invoking the calculate method defined in EmployeeListComponent. If you move the calculation to a pure pipe, Angular will recalculate the pipe expression only when its arguments change. The framework will determine if the arguments of the pipe have changed by performing a reference check. This means that Angular won't perform any recalculations unless the numeric value for an employee is updated.

Here's how to move the business calculation to a pipe called CalculatePipe:

import { Pipe, PipeTransform } from '@angular/core';

const fibonacci = (num: number): number => {
  if (num === 1 || num === 2) {
    return 1;
  }
  return fibonacci(num - 1) + fibonacci(num - 2);
};

@Pipe({
  name: 'calculate'
})
export class CalculatePipe implements PipeTransform {
  transform(val: number) {
    return fibonacci(val);
  }
}

The transform method of the pipe invokes the fibonacci function. Notice that the pipe is pure. Angular will consider all pipes pure unless you specify otherwise.

Finally, update the expression inside of the template for EmployeeListComponent:

<mat-chip-list>
  <md-chip>
    {{ item.num | calculate }}
  </md-chip>
</mat-chip-list>

That's it! Now when the user types in the text input associated with any department, the app won't recalculate the numeric value for individual employees.

In the app below you can see how much smoother the typing is!

To see the effect of the last optimization try this example on StackBlitz.

The code with the pure pipe optimization of the original application is available here.

Conclusion

When facing runtime slowdowns in an Angular app:

  1. Profile the application with Chrome DevTools to see where the slowdowns are coming from.
  2. Introduce the OnPush change detection strategy to prune a component's subtrees.
  3. Move heavy computations to pure pipes to allow the framework to perform caching of the computed values.