If you are an Angular user, you must have heard about the trackBy
function inside an *NgFor
loop. If you have never heard of it, it’s not too late to learn about it.
The trackBy
function lets Angular know how to identify items in an Array to refresh the DOM correctly when you update that array. Without trackBy
, the entire DOM elements get deleted and added again. If you want to preserve your DOM from unnecessary re-rendering when adding, deleting or reordering list elements, use the trackBy
function.
However adding this property to your NgFor
directive requires a lot of boilerplate. You need to create a function that returns the property that identifies your list element and pass that function to the directive in your template.
interface Photo {
id: string;
url: string;
name: string;
}
@Component({
selector: 'list',
standalone: true,
imports: [NgFor],
template: `
<div *ngFor="let photo of photos; trackBy: trackById"> // 👈
{{ photo.name }}
<img [src]="photo.url" [alt]="photo.name" />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ListComponent {
@Input() photos!: Photo[];
trackById(index: number, photo: Photo) { // 👈
return photo.id;
}
}
Simplify the boilerplate
To simplify the boilerplate, we can create an additional directive that handles the instantiation of this trackById
function.
@Directive({
selector: '[ngForTrackById]', // 1
standalone: true
})
export class NgForTrackByIdDirective<T extends { id: string | number }> {
@Input() ngForOf!: NgIterable<T>; // 2
private ngFor = inject(NgForOf<T>, { self: true }); // 3
constructor() {
this.ngFor.ngForTrackBy = (index: number, item: T) => item.id; // 4
}
}
Let’s go though the code:
Line 1
We prefix our directive selector with ngFor
, this way we can combine it with the NgFor
directive like this:
<div *ngFor="let photo of photos; trackById"></div>
If you are not familiar with the shorthand syntax of structural directive, the above is the simplification of:
<ng-template ngFor let-photo [ngForOf]="photos" ngForTrackById"></ng-template>
We can now easily see why we need to prefix our directive with ngFor
😇
Line 2
The @Input
is only useful for type checking. We need to obtain the type of the array to enforce strong type safety within our directive. If the type Photo
doesn’t have an id
property, and since the generic T
extends id
, we will get a Typescript error.
If we remove the id property from the type Photo
, we will see the following error:
Line 3
The goal of the directive is to set the trackBy
function of the built-in NgForDirective
. Thus we need to access the current instance of the directive NgFor
. Since we know that we are using the directive on the same VIEW element, we set the self
flag to true. This means we are only going to look for the NgFor
instance on this element.
<div *ngFor="let item of items; trackById">
// ☝️ ------------------------- 👈
<div *ngFor="let photo of photos; trackById">
// ☝️ ------------------------- 👈
</div>
</div>
If you want to use trackById
outside an NgFor
directive, the property ngFor
inside your NgForTrackByIdDirective
will be null even if you have something like this:
<div *ngFor="let photo of photos">
<div ngFortrackById> // 👈 will not work
//
</div>
</div>
Note: If we don’t set any flags, or use a different flag such as the host
flag, we will obtain the instance of the line above in the example provided. However, that is not the instance we want to work with.
Line 4
We instantiate the trackBy
function of NgFor
to track the id of our Photo
list.
Result
Now, our code becomes:
@Component({
selector: 'list',
standalone: true,
imports: [NgFor, NgForTrackByIdDirective], // 👈
template: `
<div *ngFor="let photo of photos; trackById"> // 👈
{{ photo.name }}
<img [src]="photo.url" [alt]="photo.name" />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ListComponent {
@Input() photos!: Photo[];
}
But you may say this only works for objects with an id
property. That’s true, which is why we can create a more general directive to accept any properties.
NgForTrackByPropDirective
The only small difference we need to apply to our directive is that we cannot set the trackBy
function inside the constructor since it relies on an input property. To resolve this, we will create a setter:
@Directive({
selector: '[ngForTrackByProp]',
standalone: true
})
export class NgForTrackByPropDirective<T> {
@Input() ngForOf!: NgIterable<T>;
@Input()
set ngForTrackByProp(ngForTrackBy: keyof T) { // setter
this.ngFor.ngForTrackBy = (index: number, item: T) => item[ngForTrackBy];
}
private ngFor = inject(NgForOf<T>, { self: true });
}
This directive is type safe as well.
Simplify imports
Last but not least, we can simplify the import array by creating a module that imports both directive combined with NgFor
export const NgForTrackByDirective: Provider[] = [NgForTrackByIdDirective, NgForTrackByPropDirective];
@NgModule({
imports: [NgFor, NgForTrackByDirective],
exports: [NgFor, NgForTrackByDirective]
})
export class NgForTrackByModule {}
Now you are well-equipped and have no more excuses to forget the trackBy
function or omit it due to boilerplate code.
Those two directives can be easily integrated into your project’s source code.
Enjoy using them! 🚀
You can find me on Twitter or Github.Don't hesitate to reach out to me if you have any questions.
Top comments (3)
hmm, very useful ! ... I may shamelessly copy this O.o
thanks for sharing!
Super cool article