Eyas's Blog

Use trackBy in Angular ngFor Loops and mat-table

Angular Logo (CC-BY 4.0 by Angular Team press Kit: https://angular.io/presskit).

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 *ngFor structural directive had a trackBy function that was optional. This also meant that in cases where ngFor was looping over the results of a function that are created anew each time (e.g. an array being constructed using .map and .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 | async pipe from within the ngFor body.
  • 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 | async pipe.

This also leads to a bunch of state being lost:

  • selection state inside the @for is 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 N is replaced by the N+1 task, 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>.