Angular best practice tip: use *ngFor trackBy

Performance will be significantly improved if you use *ngFor trackBy functions

Understanding the *ngFor trackBy function is essential to improve the performance of your *ngFor loops and your angular application as a whole.

What is *ngFor trackBy?

*ngFor trackBy is a function that you can pass to your *ngFor loop to tell angular how to track the items in your list. By default, angular tracks the items in your list by their identity i.e ===.

Default trackBy

Let's understand how the default trackBy is working.
Start a new Angular application:

npx @angular/cli@latest new trackby-demo --minimal --style css --routing false

change your app.component.ts to look like this:

import {Component} from '@angular/core';
import {CommonModule} from '@angular/common';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule],
  template: `
    <ul>
      <li *ngFor="let item of todo">
        <input [value]="item.title" />
      </li>
    </ul>
    <button (click)="add()">add</button>
  `,
})
export class AppComponent {
  todo = [
    {id: 1, title: 'hello'},
    {id: 2, title: 'world'},
    {id: 3, title: 'foo'},
    {id: 4, title: 'bar'},
  ];

  add() {
    this.todo.push({id: Math.random(), title: 'baz' + Math.random()});
  }
}

In this example we have a list of todo items. We are using *ngFor to loop over the items and render an input for each item. We also have a button that adds a new item to the list.
We are placing an input in each item to verify if the list elements are recreated from scratch or is the list modified and an element is just appended to the list.
You can type something in an input, if the list is recreated you will lose the state of the dom element (since it's destroyed), but in this case we see that angular rocks this *ngFor keeping the dom elements intact and just appending an element to the end of the list.

Angular will iterate on each list item and compare it to the previous list item using === since the objects in the list are the same, so are the elements in the *ngFor.

The problem

As long as the objects in the array stay the same we have no problem, the problem will start when the objects in the array will change, which often happens in the common use case of *ngFor where we iterate on a list of objects that are fetched from the server.
Let's imagine the following use case, the list is grabbed from the server, and we have a search input that will send a search request to the server and provides us with a new list.
In that case, the objects in the list will change, and angular will destroy the dom elements and recreate them from scratch.

We can try and simplify this example with the following code:

import {Component} from '@angular/core';
import {CommonModule} from '@angular/common';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule],
  template: `
    <ul>
      <li *ngFor="let item of todo">
        <input [value]="item.title" />
      </li>
    </ul>
    <button (click)="add()">add</button>
  `,
})
export class AppComponent {
  todo = [
    {id: 1, title: 'hello'},
    {id: 2, title: 'world'},
    {id: 3, title: 'foo'},
    {id: 4, title: 'bar'},
  ];
	
  clone() {
    return this.todo.map(item => {
      return {...item};
    });
  }

  add() {
    this.todo = [...this.clone(), {id: Math.random(), title: 'baz' + Math.random()}];
  }
}

Notice that on every add we are completly recreating the list, we are using the clone function to clone the list and add a new item to the list.
In the following case if you type something in the input and click the add button you will see that the input is recreated from scratch and you lose the state of the input. Regardless of losing the state, it is also a performance issue, since angular will destroy all the dom elements in the list and recreate them from scratch.

The solution

The solution is to use *ngFor trackBy function, which will replace the default === comparison with a custom comparison function.

import {Component} from '@angular/core';
import {CommonModule} from '@angular/common';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule],
  template: `
    <ul>
      <li *ngFor="let item of todo; trackBy: tbid">
        <input [value]="item.title" />
      </li>
    </ul>
    <button (click)="add()">add</button>
  `,
})
export class AppComponent {
  todo = [
    {id: 1, title: 'hello'},
    {id: 2, title: 'world'},
    {id: 3, title: 'foo'},
    {id: 4, title: 'bar'},
  ];
	
  clone() {
    return this.todo.map(item => {
      return {...item};
    });
  }
	
	tbid(index, item: typeof this.todo[0]) {
		return item.id;
	}

  add() {
    this.todo = [...this.clone(), {id: Math.random(), title: 'baz' + Math.random()}];
  }
}

Try and type a text in the input and click the add, you will see that the state of the input is preserved which means that the dom element is not recreated from scratch.

Lint rule

Since it's so common that *ngFor will get a new list, there are places that enforce the use of *ngFor trackBy function, by enabling a lint rule in @angular-eslint.

npx ng lint

You can update the .eslintrc file:

{
  "root": true,
  "ignorePatterns": [
    "projects/**/*"
  ],
  "overrides": [
    {
      "parser": "@angular-eslint/template-parser",
      "files": [
        "*.ts"
      ],
      "extends": [
        "eslint:recommended",
        "plugin:@typescript-eslint/recommended",
        "plugin:@angular-eslint/recommended",
        "plugin:@angular-eslint/template/process-inline-templates"
      ],
      "rules": {
        "@angular-eslint/directive-selector": [
          "error",
          {
            "type": "attribute",
            "prefix": "app",
            "style": "camelCase"
          }
        ],
        "@angular-eslint/component-selector": [
          "error",
          {
            "type": "element",
            "prefix": "app",
            "style": "kebab-case"
          }
        ]
      }
    },
    {
      "files": [
        "*.html"
      ],
      "extends": [
        "plugin:@angular-eslint/template/recommended",
        "plugin:@angular-eslint/template/accessibility"
      ],
      "rules": {
        "@angular-eslint/template/use-track-by-function": "error"
      }
    }
  ]
}
Read-only

notice that we added the "parser": "@angular-eslint/template-parser", and the rule: "@angular-eslint/template/use-track-by-function": "error".

Now when you run npx ng lint without the trackBy function you will get a lint error.
I do recommend adding this lint although it's not a must if the angular default comparison === is suitable for most of your use cases, and your lists are not recreated with the default comparison.

Conclusion

When placing *ngFor in your Angular application, ask yourself the following:

  • Which elements in the list are recreated, and how do I optimize it so only the needed elements of the list are actually updated, and not the whole list.