Using Pure Pipes To Generate NgFor TrackBy Identity Functions In Angular 7.2.7
One feature that I really appreciated in AngularJS was how easy it was to define a "track by" property in the ngRepeat directive. Angular 2+ also has the concept of a "track by" property in its ngFor structural directive; but, it is more laborious to consume - you have to define an identity method on your component class and then use that method as the [ngForTrackBy] input. There's no doubt that this is more flexible; but, in the vast majority of cases, I don't need flexibility in my "track by" expression. As such, I wanted to see if I could bring back some of the AngularJS magic by using a pure Pipe to generate an ngFor TrackBy identity function in Angular 7.2.7.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
In the great majority of ngFor loops, I either want to use Object Identity as the "track by" mechanism (the default behavior); or, I want to use a single property on the iteration object, such as the object's "id", "uuid", or "email" property (for example). In the latter case, the old AngularJS syntax would allow me to signify the track by property right in the ng-repeat expression:
ng-repeat="friend in friends track by friend.id"
Behind the scenes, AngularJS would take the "friend.id" expression and evaluate it against each "friend" object. In Angular 7.2.7, I want to try and re-create some of that simplicity with use of a globally-available pure Pipe:
ngFor="let friend of friends ; trackBy: ( 'id' | trackByProperty )"
In this approach, the pure Pipe is "trackByProperty", and it's receiving a single argument, "id". The "trackByProperty" Pipe is a "higher order function" will take that "id" argument and return a new Function that accepts the "friend" object and returns the friend object's "id" property as the "track by" property.
Since the "trackByProperty" is a pure Pipe, it means that it won't get re-evaluated until its arguments change. And, since the provided argument is a static string ("id"), Angular is smart enough to know that the arguments will never change and will, therefore, only evaluate this Pipe once in order to produce the trackBy Function.
This may sound complicated; but, once you see the TrackByPropertyPipe code, it will become clear:
// Import the core angular services.
import { Pipe } from "@angular/core";
import { PipeTransform } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
interface TrackByFunctionCache {
[ propertyName: string ]: <T>( index: number, item: T ) => any;
}
// Since the resultant TrackBy functions are based purely on a static property name, we
// can cache these Functions across the entire app. No need to generate more than one
// Function for the same property.
var cache: TrackByFunctionCache = Object.create( null );
@Pipe({
name: "trackByProperty",
pure: true
})
export class TrackByPropertyPipe implements PipeTransform {
// I return a TrackBy function that plucks the given property from the ngFor item.
public transform( propertyName: string ) : Function {
console.warn( `Getting track-by for [${ propertyName }].` );
// Ensure cached function exists.
if ( ! cache[ propertyName ] ) {
cache[ propertyName ] = function trackByProperty<T>( index: number, item: T ) : any {
return( item[ propertyName ] );
};
}
return( cache[ propertyName ] );
}
}
As you can see, this Pipe takes a "propertyName", and then returns a Function that implements the TrackByFunction<T> interface. The returned Function then takes the NgFor iteration object and returns the "propertyName" value. And, since these Functions are based on static property names, I'm simply caching them globally so I don't need to constantly redefine them.
To access this TrackByPropertyPipe, I just need to include it in a common "declarations" module property and I can then use it in all my component templates. To this in action, I've created a simple demo in which I have a collection of Friends that can be "refreshed". The refresh action will change the "object identity"; but, the use of the TrackByPropertyPipe should prevent the DOM (Document Object Model) nodes from being destroyed and re-created.
// Import the core angular services.
import { Component } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
interface Friend {
id: number;
name: string;
}
@Component({
selector: "my-app",
styleUrls: [ "./app.component.less" ],
template:
`
<h2>
Friends
</h2>
<p>
<a (click)="cycleFriends()">Cycle friends</a>
—
<a (click)="toggleFriends()">Toggle friends</a>
</p>
<ul *ngIf="isShowingFriends">
<ng-template
ngFor
let-friend
[ngForOf]="friends"
[ngForTrackBy]="( 'id' | trackByProperty )">
<li [mySpy]="friend.name">
{{ friend.name }}
</li>
</ng-template>
</ul>
`
})
export class AppComponent {
public friends: Friend[];
public isShowingFriends: boolean;
// I initialize the app component.
constructor() {
this.friends = this.generateFriends();
this.isShowingFriends = true;
}
// ---
// PUBLIC METHODS.
// ---
// I re-create the collection of friends, thereby breaking any "object identity"
// references to the old view-model.
public cycleFriends() : void {
console.warn( "Cycling friends collection." );
this.friends = this.generateFriends();
}
// I toggle the rendering of the friends collection.
public toggleFriends() : void {
console.warn( "Toggling friends collection." );
this.isShowingFriends = ! this.isShowingFriends;
}
// ---
// PRIVATE METHODS.
// ---
// I return a new collection of friends.
private generateFriends() : Friend[] {
return([
{ id: 1, name: "Liz" },
{ id: 2, name: "Joanna" },
{ id: 3, name: "Kim" }
]);
}
}
In this demo, I'm using the ng-template syntax instead of the *ngFor syntactic sugar so that it's a bit easier to see the [ngForTrackBy] property being defined. And, as you can see, I'm using the pure Pipe to generate a Function that uses the friend's "id" property as the "track by" expression.
Now, in order to make sure this is truly working, I've added a [mySpy] directive to each LI in the friend-loop. This directive simply logs the ngOnInit() function so that we can see when the LI DOM nodes are being created:
// Import the core angular services.
import { Directive } from "@angular/core";
import { OnInit } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Directive({
selector: "[mySpy]",
inputs: [ "mySpy" ]
})
export class SpyDirective implements OnInit {
public mySpy!: string;
// ---
// PUBLIC METHODS.
// ---
// I get called once after the inputs are bound for the first time.
public ngOnInit() : void {
console.log( `Spy initialized on element [${ this.mySpy }].` );
}
}
If all goes well, refreshing the friends collection should leave the DOM unchanged since the "id" properties remain consistent even when the "object identity" is changing. And, if we run this Angular application and refresh the collection a few times, we get the following output:
As you can see, as we cycle the friends collection - changing the "object identities" - the "spy" directive remains quiet. This is because the TrackByPropertyPipe is maintaining the "id" property across refreshes, which remains consistent. As such, the ngFor directive knows not to destroy and recreate the associated DOM nodes.
Also notice that "Getting track-by for [id]" only gets logged once. This is because it is logged from within a "pure" Pipe that Angular knows not to re-evaluate. Since the "id" argument is static, the pipe expression only gets executed once during the initial rendering of the ngFor loop.
Using a Function for the "track by" portion of the ngFor loop provides for a lot of flexibility in Angular 7.2.7. However, in the vast majority of cases, I don't need that flexibility - I just want to use the "id" (or some other property) of the ngFor iteration object. In such cases, we can use a pure Pipe to mimic some of the ease-of-use that we grew accustom to in the AngularJS days.
Want to use code from this post? Check out the license.
Reader Comments
OK. So, what you are saying, is that your tracking mechanism prevents Angular from recreating DOM nodes, unnecessarily?
If you had a mechanism for adding friends, would it trigger Angular to recreate all the entries each time a new entry is added. And then no refresh for each cycle afterwards, until another new entry is added.
Wow. I think I have got my head around this, after an evening spent researching 'pipes' & 'ngForTrackBy'. I never even knew about the latter feature.
In this example, your array never changes, but Angular doesn't know this. So, it has to take the safe option, and rerender the list every time the toggle function is used.
Essentially, within the pipe, you are caching a function that tracks, in this case, the 'id' of each object within an array of objects. By, using 'ngForTrackBy', you are signaling to Angular that it must hand over the tracking duties to your pipe. The pipe returns a tracking function, which is a requirement of 'ngForTrackBy'.
Angular no longer has to concern itself with tracking changes within these nodes, because you have guaranteed that your pipe will handle this. Every time the list is toggled, Angular no longer has to tear down & recreate the nodes associated with each list item. The end result is better application performance.
How am I doing?
@All,
After yesterday's post, I kept thinking about my
track by
use-cases. And, while a single property covers the vast majority ofngFor
needs, there are two other use-cases which would be helpful:$index
, as the identity.[ "type", "id" ]
, as a composite identity.As such, I took a stab at updating the
TrackByPropertyPipe
to cover these:www.bennadel.com/blog/3580-using-pure-pipes-to-generate-ngfor-trackby-identity-functions-for-mixed-collections-in-angular-7-2-7.htm
This should allow me to never have to define a
NgForTrackBy
method on any of my components. I think this will now cover all of my use-cases.@Charles,
Yes, more or less, you are on the right track. To be honest, I don't fully understand all of the inner-workings of how the
NgFor
managed identity. I actually just ran across an article this morning that you might find interesting:https://blog.mgechev.com/2017/11/14/angular-iterablediffer-keyvaluediffer-custom-differ-track-by-fn-performance/
In that article, Minko Gechev really dives into the "Differs" that
NgFor
is using under the hood. In fact, you can see that theNgForTrackBy
function is really just passed-down into the underlying Differ, which returns a set of changes. TheNgFor
directive than mutates the DOM in order to implement said changes.To be clear, that low stuff is really over my head; but, at the end of the day, what I think you need to understand is that Angular tries to efficiently get the DOM to mirror the view-model. And, using object identity allows Angular to keep a better link between the DOM and the view-model.
Excellent. I will check out the link. I like deep diving, even if I don't understand a lot of it.
I actually read another article on this by Netanel Basal:
https://netbasal.com/angular-2-improve-performance-with-trackby-cc147b5104e5
Basically, he suggests that Angular covers mutable objects and the ngForTrackBy is really for handling immutables.