Skip to main content
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Azeez Olaniran
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Azeez Olaniran

Using Pure Pipes To Generate NgFor TrackBy Identity Functions For Mixed Collections In Angular 7.2.7

By
Published in Comments (1)

Yesterday, I experimented with using a Pure Pipe in Angular 7.2.7 in order to generate an identity Function for use in the NgFor TrackBy input. That was my attempt to bring-forward some of the "ng-repeat" magic that we had in AngularJS. Yesterday's experiment was a success; but, it was also limited - it only worked with a single property. Most of the time, using a single property to portray identity is sufficient. However, if you need to render a collection that composes several different unique sets, it's possible that a single property will lead to identity collisions. As such, I wanted to quickly revisit yesterday's experiment, but add the ability to provide multiple properties to the pure Pipe.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

To quickly recap from yesterday, I created a pure Pipe in Angular that accepted a property name and returned a Function that plucked said property name from the given item in an ngFor structural directive:

[ngForTrackBy]="( 'id' | trackByProperty )"

Today, I want to augment the TrackByPropertyPipe to accept both a single property name, as above, and also allow for an array of property names to be passed-in:

[ngForTrackBy]="( [ 'type', id' ] | trackByProperty )"

The idea here would be that the TrackByPropertyPipe would take both the "type" property and the "id" property and generate a unique identity token that could be used to differentiate between all the items in the NgFor collection. From past experiments, we know that Angular won't regenerate inline object literals; as such, passing an array of static strings should be able to leverage the same exact "purity" mechanics of the Pipe that passing a static string was able to do.

And, while I'm at it, I wanted to allow a special property name, "$index", to be provided as a means to use the collection index as the object identity.

Groovy - here's my updated TrackByPropertyPipe code - notice that the transform() function signature is now overloaded to allow for multiple types of inputs:

// 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 names, we
// can cache these Functions across the entire app. No need to generate more than one
// Function for the same property names.
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 properties from the ngFor item.
	public transform( propertyNames: "$index" ) : Function;
	public transform( propertyNames: string ) : Function;
	public transform( propertyNames: string[] ) : Function;
	public transform( propertyNames: any ) : Function {

		console.warn( `Getting track-by for [${ propertyNames.toString() }].` );

		var cacheKey = propertyNames;

		// If the given property names are defined as an Array, then we have to generate
		// the item identity based on the composition of several item values (in which
		// each key in the input maps to a property on the item).
		if ( Array.isArray( propertyNames ) ) {

			cacheKey = propertyNames.join( "->" );

			// Ensure cached identity function.
			if ( ! cache[ cacheKey ] ) {

				cache[ cacheKey ] = function trackByProperty<T>( index: number, item: T ) : any {

					var values = [];

					// Collect the item values that will be aggregated in the resultant
					// item identity
					for ( var propertyName of propertyNames ) {

						values.push( item[ propertyName ] );

					}

					return( values.join( "->" ) );

				};

			}

		// If the property name is the special "$index" key, we'll create an identity
		// function that simply uses the collection index. This assumes that the order of
		// the collection is stable across change-detection cycles.
		} else if ( propertyNames === "$index" ) {

			// Ensure cached identity function.
			if ( ! cache[ cacheKey ] ) {

				cache[ cacheKey ] = function trackByProperty<T>( index: number, item: T ) : any {

					return( index ); // <---- Using INDEX.

				};

			}

		// By default, we'll use the provided item property value as the identity.
		} else {

			// Ensure cached identity function.
			if ( ! cache[ cacheKey ] ) {

				cache[ cacheKey ] = function trackByProperty<T>( index: number, item: T ) : any {

					return( item[ propertyNames ] ); // <---- Using VALUE.

				};

			}

		}

		return( cache[ cacheKey ] );

	}

}

As you can see, we have three code branches for each type of input: static string, "$index", and array of static strings. If an array of strings is passed-in, we have to iterate over the array, collection the relevant property values on the item, and then collapse the values down into a single token that can be used to test identity.

To see this in action, I've updated my previous demo to use a collection of people that composes two different collections: Friends and Foes. These two collections have conflicting "id" properties and must be further differentiated by "type". As such, our "trackByProperty" Pipe will receive an array of property names: ["type","id"].

// Import the core angular services.
import { Component } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

interface Person {
	type: "friend" | "foe";
	id: number;
	name: string;
}

@Component({
	selector: "my-app",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<h2>
			Friends &amp; Foes
		</h2>

		<p>
			<a (click)="cyclePeople()">Cycle people</a>
			&mdash;
			<a (click)="togglePeople()">Toggle people</a>
		</p>

		<ul *ngIf="isShowingPeople">
			<ng-template
				ngFor
				let-person let-index="index"
				[ngForOf]="people"
				[ngForTrackBy]="( [ 'type', 'id' ] | trackByProperty )">

				<li [mySpy]="person.name">
					{{ person.name }} - {{ person.type }}
				</li>

			</ng-template>
		</ul>
	`
})
export class AppComponent {

	public people: Person[];
	public isShowingPeople: boolean;

	// I initialize the app component.
	constructor() {

		this.people = this.generatePeople();
		this.isShowingPeople = true;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I re-create the collection of people, thereby breaking any "object identity"
	// references to the old view-model.
	public cyclePeople() : void {

		console.warn( "Cycling people collection." );
		this.people = this.generatePeople();

	}


	// I toggle the rendering of the people collection.
	public togglePeople() : void {

		console.warn( "Toggling people collection." );
		this.isShowingPeople = ! this.isShowingPeople;

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I return a new collection of people.
	private generatePeople() : Person[] {

		// Notice that this collection contains a mixture of two different sets of
		// people, each of which have their own set of unique ID (primary keys). As such,
		// we can't use "id" alone to define uniqueness - we have to use the combination
		// of "type" AND "id" to define uniqueness within the collection.
		return([
			{ type: "friend", id: 1, name: "Liz" },
			{ type: "friend", id: 2, name: "Steve" },
			{ type: "foe", id: 1, name: "Katrina" },
			{ type: "foe", id: 2, name: "Joe" }
		]);

	}

}

As you can see, the collection of "people" contains objects with conflicting "id" properties. As such, we need to use both the "type" and "id" properties of each item in order to maintain consistent identity across object construction. Since both these properties are being passed to the TrackByPropertyPipe, if run this Angular app and cycle the collection of people, we will see that the DOM (Document Object Model) nodes are kept in-tact:

Using a pure pipe to generate NgForTrackBy functions for mixed-type collections in Angular 7.2.7.

As you can see, as we cycle the people collection, the existing DOM nodes are left in place. This is because we are successfully using both the "type" and "id" properties to maintain consistent object identity.

This update makes me feel pretty good about the TrackByPropertyPipe. I think this essentially covers every "track by" use-case that I've had to use in an Angular application: single-property, multi-property, and collection-index. At this point, I don't think I'll have to create any specific NgForTrackBy functions in my components. Easy peasy!

Want to use code from this post? Check out the license.

Reader Comments

1 Comments

I what is still missing for me is that every item need to by tracked by it's self value. For example if i have array of strings.

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel