An incorrect track expression in a @for loop, or a missing trackBy in a
data table can often result in hard-to-track and seemingly glitchy behaviors in
your web app. Today, I’ll discuss how to properly use track and trackBy. But
first—some context:
More often than not, you’ll want to render some repeated element in Angular. You’ll see code that looks like this:
@for (taskItem of getTasks(category); track $index) {
<!-- ... -->
}
Note: Prior to Angular 17, the
*ngForstructural directive had atrackByfunction that was optional. This also meant that in cases wherengForwas looping over the results of a function that are created anew each time (e.g. an array being constructed using.mapand.filter), you’d run into performance issues.Every time the template is re-rendered, a new array is created with new elements. While newly-created array elements might be equivalent to the previous ones, Angular uses strict equality on each element to determine how to handle it.
In cases where the elements are an object type, strict equality will show that each element of the array is new.
With
@for, tracking is now mandatory, but incorrect tracking expressions could still be problematic.
What is the track expression, what does it mean to set it correctly, and what
does it mean to set it well? What would it look like when it is incorrect or
poorly set?
Let’s take a look at what happens in cases where Angular has to re-render the block:
-
For every tracked item whose track value no longer exists, Angular determines the element is no longer present and
- destroys their components recursively,
- unsubscribes from all Observables accessed through an
| asyncpipe from within thengForbody.
-
For every item who has a track value that didn’t exist before, Angular
- creates their components from scratch,
- subscribing to new Observables (i.e. by making a new HTTP request) to each
Observable it accesses via an
| asyncpipe.
This also leads to a bunch of state being lost:
- selection state inside the
@foris lost on re-render for items that no longer exist, - state like a link being in focus, or a text-box having filled-in values, would similarly go away.
- if you have side-effects in your Observable pipes, you’ll see those happen again.
The good way to use track
@for (taskItem of getTasks(category); track taskItem.id) {
<!-- Content Goes Here-->
}
Tasks could possibly be reordered, including checking an item in the middle of the list or simply moving a task to the top of the list. Any change in ordering or existence should only destory the components for the actual task IDs that got destroyed, and should merely reorder the HTML elements and JavaScript component state for the items that didn’t get removed but instead only moved around.
An okay way to use track
@for (taskItem of getTasks(category); track $index) {
<!-- Content Goes Here-->
}
$index is sometimes a perfectly reasonable way to track. However, for things
that are not actually logically described purely by the index, it could mean
more churn on change detection than you’d like. This would manifest itself as
things like inputs losing focus when they should keep it, selections being lost
in tables, etc.
For example, checking a task in the middle of the list and it being removed from
getTasks would cause:
- all prior tasks are rendered fine with minimal state changes,
- the component for each subsequent task
Nis replaced by theN+1task, causing selection on one task to possibly show up in the later one - the very last task is the one that is deleted from the DOM and has its component state cleared
Prior to Angular 17 (*ngFor)
trackBy gives you the ability to define custom equality operators for the
values you’re looping over. This allows Angular to better track insertions,
deletions, and reordering of elements and components within an ngFor block.
<ng-container
*ngFor="let taskItem of getTasks(category); trackBy: trackTask"
></ng-container>
… where trackTask is a TrackByFunction<Task>, such as:
trackTask(index: number, item: Task): string {
return `${item.id}`;
}
If you run into situations where you have Observables that are being subscribed
more often than you expect, seemingly duplicate HTTP calls being made, DOM
elements that lose interaction and selection state sporadically, you might be
missing a trackBy somewhere.
It’s not just For Loops
Any kind of data source that corresponds to repeated rows or items, especially
ones that are fetched via Observables, should ideally allow you to use
trackBy-style APIs. Angular’s MatTable (and the more general CdkTable)
support their own version oftrackByfor that purpose.
Since a table’s dataSource will often by an Observable or Observable-like
source of periodically-updating data, understanding row-sameness across updates
is very important.
Symptoms of not specifying trackBy in data tables are similar to ngFor
loops; lost selections and interaction states when items are reloaded, and any
nested components rendered will be destroyed and re-created. The experience of
trackBy-less tables might be even worse, in some cases: changing a table sort
or filtering will often be implemented at the data source level, causing a new
array of data to render once more, with all the side effects entailed.
For a table of tasks fetched as Observables, we can have:
<table mat-table [dataSource]="category.tasksObs" [trackBy]="trackTask"></table>
Where trackTask is implemented identically as a TrackByFunction<Task>.
