Implement a Search Bar on your Ionic app to filter your data
One of the most common functionalities any app has is showing lists of data.
A contact list, a task list, you name it.
Sometimes the list is so long that we can’t easily find what we’re looking for. For this, it’s a good idea to add filtering.
Today, we’ll learn how to integrate a search bar in our application, to filter a list of data we’ll get from Firestore. (Firestore is one of the places data can come from, you can fetch the data from your own APIs)
Let’s get coding
The View
The first thing we’ll do is to create our view, it’s going to be something simple so that we can
focus on the functionality, open the home.page.html
file and create the search bar component and a
list to loop through the items.
<ion-header [translucent]="true">
<ion-toolbar>
<ion-title> Blank </ion-title>
</ion-toolbar>
</ion-header>
<ion-content [fullscreen]="true">
<ion-searchbar [formControl]="searchField" debounce="100"></ion-searchbar>
<ion-list lines="none">
<ion-item *ngFor="let foodItem of foodList$ | async">
<ion-label>{{ foodItem.name }}</ion-label>
</ion-item>
</ion-list>
</ion-content>
The <ion-searchbar>
is an Ionic component that gives us a really nice looking search bar at the
top of our file.
The [formControl]="searchField"
is using Angular’s Reactive Forms to bind this searchbar to a
FormControl
called searchField
.
The debounce="100"
is the time in miliseconds the searchbar waits to emit values on key presses,
so if you set it a long time, like 2000
miliseconds, and in that span of time you press A B C,
it’s going to emit the value ABC instead of each key individually.
And below it, we have a regular <ion-list>
, where we’re looping through an Observable
called
foodList$
, and we’re using the async
pipe to unwrap it and subscribe to it directly in the
template.
A couple of things you need to keep in mind:
formControl
is part of the ReactiveFormsModule
, so we need to add it to our current module’s
imports
array:
...
import { ReactiveFormsModule } from '@angular/forms';
@NgModule({
...,
imports: [
...,
ReactiveFormsModule,
],
})
export class HomePageModule {}
And you’ll get a lot of errors in the template, because none of the properties we’re declaring exist yet 😅
Now, our job is to:
- Create all the variables we need.
- Get the data from Firestore.
- Create the
Observable
that will merge everything together and filter the list.
Creating the variables
Go ahead and open home.page.ts
, this is where our work will happen, the class doesn’t have much
yet, so it should look a bit like this:
import { Component } from '@angular/core';
@Component({
selector: 'app-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
export class HomePage {
constructor() {}
}
First, we’ll create the searchField
control we are binding in the template, for that, let’s import
FormControl
from Angular Forms, and create the class variable:
import { Component } from '@angular/core';
import { FormControl } from '@angular/forms';
export class HomePage {
searchField: FormControl;
constructor() {
this.searchField = new FormControl('');
}
}
Notice that we’re also initializing it in the constructor using new FormControl('')
with a value
of ''
.
Next, we want to create the foodList$
variable, this is going to be an Observable
.
import { Component } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Observable } from 'rxjs';
export class HomePage {
searchField: FormControl;
foodList$: Observable<FoodItem[]>;
constructor() {
this.searchField = new FormControl('');
}
}
interface FoodItem {
name: string;
}
Notice that we did 3 things here:
- We imported
Observable
fromrxjs
. - We created
foodList$
and gave it a type ofObservable<FoodItem[]>
, meaning that Observable contains a list of food items. - We created the FoodItem interface (Which is optional, I just like having it typed like that).
Now all the work is going to happen inside of the ngOnInit()
lifecycle hook, but before we get
into that, we need to make 2 things to make ngOnInit()
work.
We need to import OnInit
, and tell our class it needs to implement it:
import { Component, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Observable } from 'rxjs';
import { Firestore } from '@angular/fire/firestore';
export class HomePage implement OnInit{
searchField: FormControl;
foodList$: Observable<FoodItem[]>;
constructor(private readonly firestore: Firestore) {
this.searchField = new FormControl('');
}
ngOnInit() {}
}
interface FoodItem {
name: string;
}
We also injected our instance of Firestore
into the constructor, but this is optional, Firestore
is where I’m getting the data right now, but you could have gotten the data from anywhere you want.
Inside of our ngOnInit()
method, the first thing we want to do is to create an observable for the
value of the searchterm we’re getting from our searchbar.
We can do that with ReactiveForms, listening to an Observable that streams the value of our
FormControl
.
For the next few code snippets, I’m going to only work on the OnInit function, and I’ll be adding the relevant import for rxjs and firestore.
import { Firestore } from '@angular/fire/firestore';
import { Observable } from 'rxjs';
async ngOnInit() {
const searchTerm$ = this.searchField.valueChanges;
}
The form control has a property called valueChanges
which is an observable that streams whatever
we have for the form control’s value.
There’s one small issue here, it won’t emit any value until we start typing in the searchbar, and we
want it to emit an initial value of ''
so that we can show our unfiltered list.
For that, we can use an rxjs
operator called startWith
:
import { Firestore } from '@angular/fire/firestore';
import { Observable } from 'rxjs';
import { startWith } from 'rxjs/operators';
async ngOnInit() {
const searchTerm$ = this.searchField.valueChanges.pipe(
startWith(this.searchField.value)
);
}
We’re letting our Observable know, that the first value it’s going to emit is the value we used to
initialize our form control, in this case, ''
.
Then, we’re going to get the list of items from Firestore:
import { Firestore, collectionData, query, collection } from '@angular/fire/firestore';
import { Observable } from 'rxjs';
import { startWith } from 'rxjs/operators';
async ngOnInit() {
const searchTerm$ = this.searchField.valueChanges.pipe(
startWith(this.searchField.value)
);
const foodList$ = collectionData(query(collection(this.firestore, 'foodList')));
}
Let’s zoom into this line:
const foodList$ = collectionData(query(collection(this.firestore, 'foodList')));
In this line, collectionData()
is returning an Observable from a Firestore query. Our query is
only passing the reference to the collection we want to get, without any conditions, this means it
will bring all of the documents we have in the foodList
collection.
And lastly, we want to plug everything together to feed the foodList$
observable with some real
data.
import { Firestore, collectionData, query, collection } from '@angular/fire/firestore';
import { combineLatest, Observable } from 'rxjs';
import { map, startWith } from 'rxjs/operators';
async ngOnInit() {
const searchTerm$ = this.searchField.valueChanges.pipe(
startWith(this.searchField.value)
);
const foodList$ = collectionData(query(collection(this.firestore, 'foodList')));
this.foodList$ = combineLatest([foodList$, searchTerm$]).pipe(
map(([foodList, searchTerm]) =>
foodList.filter(
(foodItem) =>
searchTerm === '' ||
foodItem.name.toLowerCase().includes(searchTerm.toLowerCase())
)
)
);
}
Let’s zoom in this last change, because it is doing a lot of things:
this.foodList$ = combineLatest([foodList$, searchTerm$]).pipe(
map(([foodList, searchTerm]) =>
foodList.filter(
foodItem =>
searchTerm === '' || foodItem.name.toLowerCase().includes(searchTerm.toLowerCase())
)
)
);
Here’s the breakdown:
First, we’re telling our app that this.foodList$ = combineLatest([foodList$, searchTerm$]);
Reading from the docs, combineLatest
works this way: “When any observable emits a value, emit the
last emitted value from each”.
Which means that, when any of the observables we have in there (foodList$, searchTerm$) emits a value, combine latest is going to create a new Observable with the values emited from the observables it’s getting as parameters.
Since that’s not what we need, we need to use those values and transform them a little bit into
something that fits our use case, that’s where .pipe()
comes into play.
We can pipe()
operators together to access the interval values of our observables, modify them,
and then return new Observables out of those changes.
In this case we’re using the map()
operator, which is taking the internal values of those
Observables, so the actual foodList
array, and the actual string value we get from our searchbar.
this.foodList$ = combineLatest([foodList$, searchTerm$]).pipe(map(([foodList, searchTerm]) => {}));
What the map()
operator does is that it let’s you take that combination of values, and return one
modified value instead.
In our case, we’re telling it we want to return the actual food list, but not with all of the items,
we’re using the Array.filter()
method to tell it to only return an item if the item’s name
includes the string we’re passing in our searchbar.
this.foodList$ = combineLatest([foodList$, searchTerm$]).pipe(
map(([foodList, searchTerm]) =>
foodList.filter(
foodItem =>
searchTerm === '' || foodItem.name.toLowerCase().includes(searchTerm.toLowerCase())
)
)
);
Oh, and we’re also telling it to return ALL of the items, if the searchTerm === ''
, which, if you
recall correctly is the initial value we’re emitting for the searchTerm$
Observable.
By the way, filter()
is an array function, if like me, you jumped into Ionic development without a
JS background, I recommend you take either
Beginner JavaScript or
ES6 for Everyone from Wes Bos, the ES6 course helped me learn
enough JS that I started realizing how many things JS already does I was trying to re-invent,
allowing me to remove a lot of code I didn’t need in the first place.
And that’s it', every time you type something it’s going to try and match it against the food’s name property.
If you have any questions or think I might be able to help with anything don’t hesitate to reach out, you can easily find me on Twitter as @javebratt
Also, the entire source code is available at Github,
you’ll find 2 branches there, main
contains this one, and there’s another branch that contains the
previous promise-based code.