Using A Compound Track-By Expression With ngRepeat In AngularJS
In AngularJS 1.2, the ngRepeat directive added the track-by expression which allows AngularJS to map DOM (Document Object Model) nodes onto view-model values. This was a boon for performance as it meant that DOM nodes didn't have to be destroyed and recreated whenever the underlying object reference changed. Typically, I use the primary-key / id of the given object as the track-by expression. But, when you're rendering a collection of mixed data, this can lead to the [ngRepeat:dupes] error. One way to get around this is by using a compound track-by expression that results in a set-wide unique value.
Run this demo in my JavaScript Demos project on GitHub.
If you look at the Regular Expression that AngularJS uses when parsing the ngRepeat directive value, the track-by portion looks like this:
([\s\S]+?)
If you are not familiar with how Regular Expressions work, this basically matches anything. Which means, the track-by expression can contain just about anything in it and it will picked up by AngularJS. Under the hood, this match then gets passed through the $parse() service and is used to extract the track-by value from each object in the ngRepeat loop.
Ultimately, this means that any valid AngularJS expression can be used in the track-by portion of the ngRepeat. Which, in the case of mixed-data collections, means that we can produce a unique track-by value using an ngRepeat expression that combines multiple values.
To see this in action, I've created a demo in which we are rendering both Friends and Enemies in the same ngRepeat. Since this is an aggregation of two collections, we have overlapping ID values; but, each collection has a distinct "type". To create a set-wide unique value, for the ngRepeat, we can concat the type and the non-unique ID:
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Using A Compound Track-By Expression With ngRepeat In AngularJS
</title>
</head>
<body ng-controller="AppController">
<h1>
Using A Compound Track-By Expression With ngRepeat In AngularJS
</h1>
<h2>
You have {{ $scope.friends.length }} friends
and {{ $scope.enemies.length }} enemies.
</h2>
<ul>
<!--
When using the ngRepeat, we have to output a mixed collection of items
that has non-unique IDs. If all we did was track-by the "id", we'd get
the [ngRepeat:dupes] error. However, we can create a unique tracking value
by employing a compound track-by expression that combines two different
fields from the iteration object.
-->
<li
ng-repeat="person in people track by ( person.type + person.id )"
ng-switch="person.type">
{{ person.name }}
—
( {{ person.id }} )
<span ng-switch-when="friend">friend, yay!</span>
<span ng-switch-when="enemy">enemy, meh.</span>
</li>
</ul>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.5.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
angular.module( "Demo", [] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the root of the application.
angular.module( "Demo" ).controller(
"AppController",
function AppController( $scope ) {
$scope.friends = [
{
id: 1,
name: "Kim",
type: "friend"
},
{
id: 2,
name: "Sarah",
type: "friend"
},
{
id: 3,
name: "Tricia",
type: "friend"
}
];
$scope.enemies = [
{
id: 1,
name: "Tessa",
type: "enemy"
},
{
id: 2,
name: "Helen",
type: "enemy"
}
];
// I hold the collection of people that we are going to output. Notice
// that our collection contains two sets of objects that have overlapping
// ID values, but have distinct TYPE values.
$scope.people = $scope.friends.concat( $scope.enemies );
}
);
</script>
</body>
</html>
As you can see, the track-by expression in our demo is:
track by ( person.type + person.id )
While the person.id value is not unique, the compound expression is unique. This allows AngularJS to properly map the DOM node onto the underlying object. And, when we run this page, we get the appropriate output:
In previous posts, I've accomplished the same thing by calculating a "uid" and storing it in the collection itself. However, anytime we can stop storing something in the view-model, it feels like one less thing to keep track of (no pun intended). As such, compound track-by expressions feel like a nice win.
Want to use code from this post? Check out the license.
Reader Comments
Thanks for the handy tip!
@Francis,
My pleasure!
Have you considered using "track by $index"?
It will still give each repeated value a unique id (0...n), and still prevents storing data in the view model.
@Ben G.,
In this case the "$index" would definitely work since the collection isn't dynamic. But, technically speaking, since the collection isn't dynamic, you don't really even need to use the track-by expression anyway.
If the collection were ever shuffled, or if an item was added to the front (unshifted), though, the "$index" would not work as expected. It might still prevent some DOM-element destruction; but, I think you'd still get a lot of DOM manipulation and maybe some unexpected behavior.
That said, I think I could understand the use of "$index" a bit more. I think I should dig through the ngRepeat directive and see how it actually uses the track-by a bit more closely to fully understand the repercussions.
Nice article! I found an explanation here (and a solution) for a problem I didn't find elsewhere.
@Amy,
Awesome - glad I could help!
I just want to point out that I like your in-depth articles but the code snippets really bugs me out. The reason being the line height (vertical line spacing between lines) is way too much. What could have taken a single scroll to read entire code, now takes multiple scrolls, which is unnecessary in my view.
Thanks!