A list of words that's filtered when a user is typing in the searchbox

Understanding the trade-offs

Every approach starts with the same upfront work, since the initial set of elements must be created and rendered regardless of the strategy. However, once the user begins typing, the combination of the hidden attribute and a trackBy function pulls ahead of the field.

When you think through the mechanics, the outcome is logical. The hidden attribute simply toggles the visibility of an element via a boolean property, so the cost of instantiating the components — represented here by the delay pipe — is paid exactly once. The competing strategies tear down and rebuild the elements continuously, which means the delay pipe is executed again and again, holding up the rendering process. Each invocation of the pipe is logged to confirm this behavior.

Toward the end of the demonstration, the advantage becomes unmistakable. Only the implementation that pairs hidden with trackBy manages to stay responsive while the user rapidly types "a" and then clears the search box without pause.

The fastest code is the code which does not run. - Robert Galanakis

Comparing [hidden] to *ngIf

The measurements show that all three variants perform identically at the start, and they also behave the same when the displayed list is shrinking. Consider this: the collection starts out complete, and the user enters "karma" into the search field. Since every element is already present in the DOM, the ones that no longer match are simply removed and discarded.

The real divergence shows up in the opposite scenario, when the result set is expanding. Here, the hidden approach is clearly superior. Imagine the user has narrowed the list down to just a few entries and then deletes the search term. For the implementations that avoid hidden, all the previously destroyed elements must be instantiated and rendered from scratch. That extra work is exactly where the performance gap appears.

The role of trackBy

Adding a trackBy function is a key technique for any client-side filter that aims to be efficient.

Within an *ngFor loop, trackBy provides a unique identifier for each item in the collection. Angular leverages this identity to determine whether the DOM actually requires changes, such as inserting or removing nodes. When an object is already rendered, Angular reuses the existing element instead of rebuilding and reattaching it.

This explains why the variants that use trackBy outpace the one that does not. The latter is forced to create and render every component again with each keystroke. That is hardly ideal.

For small collections, the gap may be negligible, but the benefit compounds as the list grows. The hidden-based solution renders the full collection at all times, which amplifies the importance of trackBy. In the other strategies, the collection being rendered is smaller, and any freshly added items have to be re-rendered anyway, so the effect of trackBy is less pronounced.

Implementation details

The delay pipe

The delay pipe simulates the processing that occurs inside each rendered element. This processing could stem from your own code, from third-party libraries, or from Angular's own internal operations.

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'delay',
})
export class DelayPipe implements PipeTransform {
  transform(value: string, delay: number, invoker: string): string {
    console.log(`[${invoker}] invoked delay pipe`);
    const stopAt = Date.now() + delay;
    while (Date.now() < stopAt) {}
    return value;
  }
}

A component based on *ngIf

This component walks through the words$ collection, using the *ngIf directive to display only the entries that satisfy the query.

import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
  BehaviorSubject,
  combineLatest,
  delay,
  distinctUntilChanged,
  filter,
  map,
} from 'rxjs';
import { WordsService } from '../words.service';

@Component({
  selector: 'app-ng-if-with-trackby',
  template: `
    <h2>*ngIf with trackBy</mark></h2>

    <ng-container *ngFor="let word of words$ | async; trackBy: trackByWord">
      <div *ngIf="word.visible">
        {{ word.word | delay: wordsService.delay:'ngIfWithTrackBy' }}
      </div>
    </ng-container>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NgIfWithTrackbyComponent {
  words$ = combineLatest([
    this.wordsService.words$,
    this.wordsService.query$,
  ]).pipe(
    map(([words, query]) => {
      const queryParts = query.split(' ');
      return words.map((word) => {
        return {
          word,
          visible: queryParts.every((q) => word.includes(q)),
        };
      });
    })
  );

  constructor(public wordsService: WordsService) {}

  trackByWord(_: number, { word }: { word: string }) {
    return word;
  }
}

A component that filters in TypeScript

This component iterates over the complete set of matched words, where the filtering is performed in the TypeScript layer based on the current query.

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { BehaviorSubject, combineLatest, delay, distinctUntilChanged, filter, map } from 'rxjs';
import { WordsService } from '../words.service';

@Component({
  selector: 'app-ts-filter-with-trackby',
  template: `
    <h2>TS filter with trackBy</h2>

    <ng-container *ngFor="let word of words$ | async; trackBy: trackByWord">
      <div>{{ word | delay: wordsService.delay:'tsFilterWithTrackBy' }}</div>
    </ng-container>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TsFilterWithTrackbyComponent {
  words$ = combineLatest([
    this.wordsService.words$,
    this.wordsService.query$,
  ]).pipe(
    map(([words, query]) => {
      const queryParts = query.split(' ');
      return words.filter((word) => queryParts.every((q) => word.includes(q)));
    })
  );

  constructor(public wordsService: WordsService) {}

  trackByWord(_: number, word: string) {
    return word;
  }
}

A component using the hidden attribute

This component also iterates over the words$ collection, but it applies the hidden attribute to conceal the words that do not match the query.

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { BehaviorSubject, combineLatest, delay, distinctUntilChanged, filter, map } from 'rxjs';
import { WordsService } from '../words.service';

@Component({
  selector: 'app-hidden-with-trackby',
  template: `
    <h2>[hidden] with trackBy</h2>

    <ng-container *ngFor="let word of words$ | async; trackBy: trackByWord">
      <div [hidden]="!word.visible">
        {{ word.word | delay: wordsService.delay:'hiddenWithTrackBy' }}
      </div>
    </ng-container>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HiddenWithTrackbyComponent {
  words$ = combineLatest([
    this.wordsService.words$,
    this.wordsService.query$,
  ]).pipe(
    map(([words, query]) => {
      const queryParts = query.split(' ');
      return words.map((word) => {
        return {
          word,
          visible: queryParts.every((q) => word.includes(q)),
        };
      });
    })
  );

  constructor(public wordsService: WordsService) {}

  trackByWord(_: number, { word }: { word: string }) {
    return word;
  }
}

Testing hidden elements

Confirming that an element has been removed is straightforward — it is simply absent from the DOM. The same cannot be said for elements that are set to hidden, as they remain permanently present in the document structure. Fortunately, most testing libraries include utilities that allow you to assert whether an element is currently visible to the user.

Final Thoughts

The hidden attribute offers a straightforward yet effective mechanism for concealing elements from both visual users and assistive technologies. When you need to frequently toggle the visibility of items within a large dataset, leveraging hidden delivers a noticeably smoother experience. Resist the urge to default to *ngIf in every situation.

An alternative strategy for boosting client-side rendering performance is implementing a virtual scroller, such as the one provided by Angular Material's CDK. This technique enhances speed by only rendering a limited subset of the collection's elements at any given time. However, this approach was not suitable for our specific use case because the resulting behavior did not align with our requirements.

<!-- static -->
<div hidden>I am hidden</div>
<!-- dynamic -->
<div [attr.data-hidden]="hiddenExpression">`hiddenExpression` decides if I'm visible</div>
Enter fullscreen mode Exit fullscreen mode

You can access the complete source code from this article on GitHub or run it interactively on StackBlitz.


Follow the author on Twitter at @tim_deschryver | Subscribe to the Newsletter | This article was originally published on timdeschryver.dev.