High Performance List Filtering in Ionic & Angular

High Performance List Filtering in Ionic & Angular

Follow Josh Morony on

Displaying a list with data is perhaps one of the most common elements of a mobile application, and adding the ability to search and filter those lists is also a common feature. We need to be able to do this in our Ionic applications, and it needs to be done in a way that is going to provide a high level of performance.

The obvious answer, if you are familiar with Angular and the kinds of tools it provides, would be to use a pipe. Pipes take some data, modify it in some way, and return it to be displayed to the user in the new format – perfect for what we need. Except it’s not.

If you’ve used Ionic 1 (or AngularJS) in the past, you may have used the default filter or orderBy pipes at some point. Many apps need to filter and sort data, and you can indeed do this with a pipe in Angular if you want to, so why the heck isn’t it available by default? This is an intentional decision by the Angular team, and the reason for it is quite interesting. If you’re interested in reading more about it take a look at the pipes documentation.

The short answer is that it comes down to performance. Filtering and sorting data are expensive operations and, given how change detection works in Angular, if we were to use a pipe to filter data in a list these operations may have to be performed on every change detection cycle. Running expensive operations frequently is obviously not a great idea. Given that, the reasoning to leave these pipes out of Angular makes sense:

The filter and orderBy have often been abused in Angular 1 apps, leading to complaints that Angular itself is slow. That charge is fair in the indirect sense that Angular 1 prepared this performance trap by offering filter and orderBy in the first place. - angular.io

We’ve established that using pipes to filter data can be a bad idea, but that brings up the question: how do we filter data without pipes? It’s a common feature, and we can’t just abandon something because it performs poorly.

The answer is quite simple, we move the filtering logic to the component itself. Rather than filtering the data on the fly with a pipe, we can implement a function in our component to handle filtering the data.

In this tutorial, we are going to walk through how to filter list data in an Ionic & Angular application, with a focus on performance and usability. Here’s a quick look at what we’ll be building:

Ionic List Searching

Before we Get Started

Last updated for Ionic 4.0.0

This tutorial assumes you already have a basic level of understanding of Ionic & Angular. If you require more introductory level content on Ionic I would recommend checking out my book or the Ionic tutorials on my website.

1. Generate a new Ionic Application

Let’s start out by generating a new blank Ionic application. The application is just going to have a single page that will display a list with a search bar.

Run the following command to generate a new Ionic application:

ionic start ionic-search-filter blank --type=angular

We’re also going to require a data service to help with filtering data, so we will set that up now too.

Run the following command to generate a Data service:

ionic g service services/Data

Now we’ve got everything we need set up, so let’s get started.

2. Basic Filtering

As I described in the introduction, we’re not going to be using a pipe to filter our data. Instead, we are going to handle it in our Data service. The Data service will store some static data, and we will create a function that we can call to return a subset of that data based on some search criteria.

Modify src/app/services/data.service.ts to reflect the following:

import { Injectable } from "@angular/core";

@Injectable({
  providedIn: "root"
})
export class DataService {
  public items: any = [];

  constructor() {
    this.items = [
      { title: "one" },
      { title: "two" },
      { title: "three" },
      { title: "four" },
      { title: "five" },
      { title: "six" }
    ];
  }

  filterItems(searchTerm) {
    return this.items.filter(item => {
      return item.title.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1;
    });
  }
}

We set up an items member variable that we then fill with an array of data in the constructor. This is our default set of data. We then also implement a filterItems function that will take in a searchTerm and provide us back an array containing only the elements that match the search criteria.

When working with arrays, we have two very useful functions that we can take advantage of: ‘filter’ and ‘map’. If you would like an in-depth explanation of these I would recommend watching Filtering, Mapping, and Reducing Arrays in Ionic. In short, a “filter” will remove elements from an array based on some function a “map” will change elements in an array based on some function.

In the code above we filter the items array by providing filter with a function that will only return true for items where the searchTerm is contained somewhere within the title string. We then return that filtered array, which will now only contain items that match the search criteria.

Modify src/app/home/home.page.ts to reflect the following:

import { Component, OnInit } from "@angular/core";
import { DataService } from "../services/data.service";

@Component({
  selector: "app-home",
  templateUrl: "home.page.html",
  styleUrls: ["home.page.scss"]
})
export class HomePage implements OnInit {
  public searchTerm: string = "";
  public items: any;

  constructor(private dataService: DataService) {}

  ngOnInit() {
    this.setFilteredItems();
  }

  setFilteredItems() {
    this.items = this.dataService.filterItems(this.searchTerm);
  }
}

Now that we have a way to filter the data, we just need to take advantage of it. In this class, we also have an items member variable that we will use to store the filtered array returned from the Data provider. We also have a searchTerm that the user will be able to control, and this is supplied to the setFilteredItems function which will call the filter function we just defined in the provider.

We initially call setFilteredItems with no searchTerm so that the default data will display, and then every time we call setFilteredItems() after that, it will return an array of data based on whatever the current searchTerm is set as.

Modify src/app/home/home.page.html to reflect the following:

<ion-header>
  <ion-toolbar>
    <ion-title>
      Ionic Blank
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content padding>
  <ion-searchbar
    [(ngModel)]="searchTerm"
    (ionChange)="setFilteredItems()"
  ></ion-searchbar>

  <ion-list>
    <ion-item *ngFor="let item of items">
      {{ item.title }}
    </ion-item>
  </ion-list>
</ion-content>

We’ve added a search bar here so that the user can define their own search term. We have two-way data binding set up on searchTerm, so as soon as the user modifies the value in <ion-searchbar> the value in our class will also change. Then we listen for the (ionChange) event to detect when the user has modified the search, and we call setFilteredItems() to trigger the filtering of the data.

3. Filtering with Observables

We have a list set up now that filters data without using a pipe, but we can take that even further. We’re currently searching every time the user types a value into the search bar.

If I’m searching for coffee I don’t need to see the results for c, co, cof, coff, and coffe. I don’t necessarily need the search to happen until I’m done typing. Ideally, we want to give the user some time to finish typing their search before triggering a search, and we can do this quite easily with an Observable. It’s certainly not bad to give instant search results for every letter the user types, but in some cases it might not be worth the performance hit.

Angular provides an Observable that we can use with inputs that can be accessed through a FormControl. This will allow us to listen for input in a more advanced way. Let’s take a look.

In order to use FormControl we need to use the ReactiveFormsModule instead of the FormsModule that is included by default in our pages module file. This means that we won’t be able to use [(ngModel)] anymore, and we will instead have to use methods of reactive forms.

Modify src/app/home/home.module.ts to include the ReactiveFormsModule:

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { IonicModule } from "@ionic/angular";
import { ReactiveFormsModule } from "@angular/forms";
import { RouterModule } from "@angular/router";

import { HomePage } from "./home.page";

@NgModule({
  imports: [
    CommonModule,
    ReactiveFormsModule,
    IonicModule,
    RouterModule.forChild([
      {
        path: "",
        component: HomePage
      }
    ])
  ],
  declarations: [HomePage]
})
export class HomePageModule {}

Modify src/app/home/home.page.ts to reflect the following:

import { Component, OnInit } from "@angular/core";
import { DataService } from "../services/data.service";
import { FormControl } from "@angular/forms";
import { debounceTime } from "rxjs/operators";

@Component({
  selector: "app-home",
  templateUrl: "home.page.html",
  styleUrls: ["home.page.scss"]
})
export class HomePage implements OnInit {
  public searchControl: FormControl;
  public items: any;

  constructor(private dataService: DataService) {
    this.searchControl = new FormControl();
  }

  ngOnInit() {
    this.setFilteredItems("");

    this.searchControl.valueChanges
      .pipe(debounceTime(700))
      .subscribe(search => {
        this.setFilteredItems(search);
      });
  }

  setFilteredItems(searchTerm) {
    this.items = this.dataService.filterItems(searchTerm);
  }
}

We are now importing FormControl and the debounceTime operator. We set up a new member variable called searchControl, and then we create a new instance of FormControl and assign it to that. You will see how we then tie that to the <ion-searchbar> in our template in just a moment.

With the FormControl set up, we can subscribe to the valueChanges observable that will emit some data every time that the value of the input field changes. We don’t just listen for changes though, we also pipe (piping operators onto an observable allows us to modify the observable and return a new observable) the debounceTime operator, which allows us to specify a time that we want to wait before triggering the observable. If the value changes again before the debounceTime has expired, then it won’t be triggered. This will stop the setFilteredItems function from being spam called and, depending on how fast the user types, it should only be called once per search. In this case, we are setting a debounceTime of 700 milliseconds, but you can tweak this to be whatever you like.

Modify src/app/home/home.page.html to reflect the following:

<ion-header>
  <ion-toolbar>
    <ion-title>
      Ionic Blank
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-searchbar [formControl]="searchControl"></ion-searchbar>

  <ion-list>
    <ion-item *ngFor="let item of items">
      {{ item.title }}
    </ion-item>
  </ion-list>
</ion-content>

The final piece of the puzzle is to connect our <ion-searchbar> to the searchControl we created, which we do here with [formControl]. Also notice that we have removed the (ionChange) listener, because the filtering function will now be triggered by the Observable and we can get the search value from the FormControl.

4. Improving the User Experience

Our list filtering is designed pretty nicely now, and it should perform very well. However, since we have added this debounceTime it causes a slight delay that will certainly be perceptible to the user. Whenever we do something “in the background” we should indicate to the user that something is happening, otherwise it will appear as if the interface is lagging or broken.

A delay is fine, and an artificial delay is sometimes even beneficial, but you definitely don’t want to leave the user wondering “is this just slow or is it frozen?“.

We’re going to make a change now that won’t have any effect on performance, but it will have an impact on the user’s perception of the responsiveness of the app. We’re simply going to add a loading spinner that will display when a search is in progress.

Modify src/app/home/home.page.html to reflect the following:

<ion-header>
  <ion-toolbar>
    <ion-title>
      Ionic Blank
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-searchbar
    [formControl]="searchControl"
    (ionChange)="onSearchInput()"
  ></ion-searchbar>

  <div *ngIf="searching" class="spinner-container">
    <ion-spinner></ion-spinner>
  </div>

  <ion-list>
    <ion-item *ngFor="let item of items">
      {{ item.title }}
    </ion-item>
  </ion-list>
</ion-content>

We’ve added an <ion-spinner> now, which simply shows a little spinner animation, and we are only displaying that when searching evaluates to be true (we will define this in a moment). Also notice that we’ve added the (ionChange) listener back in, but this time we will be using it to determine when the user is searching.

Modify src/app/home/home.page.ts to reflect the following:

import { Component } from '@angular/core';
import { FormControl } from '@angular/forms';
import { NavController } from 'ionic-angular';
import { Data } from '../../providers/data';
import 'rxjs/add/operator/debounceTime';

@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {

    searchTerm: string = '';
    searchControl: FormControl;
    items: any;
    searching: any = false;

    constructor(public navCtrl: NavController, public dataService: Data) {
        this.searchControl = new FormControl();
    }

    ionViewDidLoad() {

        this.setFilteredItems();

        this.searchControl.valueChanges.debounceTime(700).subscribe(search => {

            this.searching = false;
            this.setFilteredItems();

        });


    }

    onSearchInput(){
        this.searching = true;
    }

    setFilteredItems() {

        this.items = this.dataService.filterItems(this.searchTerm);

    }
}

Initially, we set searching to be false, and we set it to be true when the user starts searching. Once our observable triggers and we call the filter function, we set searching to be false again so that the spinner is hidden.

Now we just need to add a bit of styling to the spinner to center it.

Modify src/app/home/home.page.scss to reflect the following:

.spinner-container {
  width: 100%;
  text-align: center;
  padding: 10px;
}

Summary

The methods described in this tutorial should help you create high-performance list filtering for just about any scenario. It’s not always necessary to use the Observable based approach when filtering for basic lists, but you will certainly see a performance improvement for filtering large lists of data.

Check out my latest videos: