I have been working on a small Ionic app and recently needed to build some search functionality. Overall, the Ionic Search Bar is very easy to use and most of the time you can just use the ionChange
event to fire some method in your component which will perform the filtering in your data set. You can then return that data set to your UI so it can be bound to a table or whatever you use to render your data.
While this does work, it can be hard to follow the flow of your data. Additionally, if your data source is continually updating which is the case with Firebase, you now need to set up your filtering logic in a way so that any data set updates also are filtered.
I’ve used the Angular Material Table and really like the way that it is recommended to have it set up. Basically you can define the data source, a pager, sorting, etc and when any of those observables fire, you can trigger your data source to refresh. I decided to see if I could make this pattern work with the Ionic Search Bar.
Let’s look at the code for fetching our initial collection.
export class HomePage {
items$: Observable<Client[]>;
constructor(db: AngularFirestore) {
this.items$ = db.collection<Client>(
'clients',
ref => ref.orderBy('firstName', 'asc')
).valueChanges();
}
This call is super simple and just grabs the list of clients sorted by first name. I assign the items$
variable which the UI binds to using the async pipe. This ensures that your subscriptions are cleaned up when you leave the page.
Using the
$
suffix is a convention that easily helps you identify that the variable is an observable.
Now, let’s begin to add the filtering functionality. We want to take all of our data from our source and then filter that data based on whatever the value of our search bar is. What this means is that whenever either the data source, or the filter change, we want the observable to fire. At first glance, there are two operators that might work; forkJoin
and combineLatest
. There is an important difference between these two operators though. combineLatest
will fire any time that the source observables fire while forkJoin
only fires every time that both operators fire. In this case, our source data might never change while we are on the page but our filter could change a lot so that means we will want to use combineLatest
.
Let’s go ahead and get our search bar set up. First, add it to your template. We are going to use the Angular ViewChild
to bind the search bar in our component. Our template will look like
<ion-searchbar #searchBar></ion-searchbar>
<ion-list>
<ion-item
*ngFor="let client of items$ | async"
[routerLink]="'/clients/' + client.id"
[detail]="true"
>
<ion-label class="ion-text-wrap">
<h2>
</h2>
</ion-label>
</ion-item>
</ion-list>
As you can see, we’re just adding our search bar and giving it a name so it can be referenced from the component code behind.
In our component code, we just need to add
@ViewChild(IonSearchbar, { static: true }) searchBar: IonSearchbar;
Now, let’s get the filter observable set up.
const searchFilter$ = this.searchBar.ionChange.pipe(
map(event => (event.target as HTMLInputElement).value),
startWith("")
);
We are taking our searchBar variable defined above and starting with the ionChange observable and mapping the value. This is basically the same as binding the (ionChange)
event in your template. In the map
operator, we are fetching the value of the target input using event.target.value
. Thanks to the map, searchFilter$
ends up being an Observable<string>
.
Next, you’ll see the startWith
operator. The one gotcha of combineLatest
is that it only fires after all of the source observables fire at least once. Since ionChange
only fires when you change the value in the search bar, your page would be initially empty until you change your search filter. By using the startWith
operator, we give it an initial value to fire with. In this case, we just want the filter to be an empty string.
Putting It All Together
Now that we have our filter all set up, we can now set up our final observable using combineLatest
.
this.items$ = combineLatest([clients$, searchFilter$]).pipe(
map(([clients, filter]) =>
clients.filter(
state =>
state.firstName.toLowerCase().indexOf(filter.toLowerCase()) !== -1
)
)
);
I’ve taken our initial data observable and renamed it to clients$
which is just a local variable in the constructor. Our template is still binding to items$
so nothing will change in your template. In combineLatest
, we are passing in our two source observables. When either one fires, we pass the resulting data into the map operator and perform our filtering.
Puttting it all together, your final component should look like this now
export class HomePage {
items$: Observable<Client[]>;
@ViewChild(IonSearchbar, { static: true }) searchBar: IonSearchbar;
constructor(db: AngularFirestore) {
const clients$ = db.collection<Client>(
'clients',
ref => ref.orderBy('firstName', 'asc')
).valueChanges();
const searchFilter$ = this.searchBar.ionChange.pipe(
map(event => (event.target as HTMLInputElement).value),
startWith('')
);
this.items$ = combineLatest([
clients$,
searchFilter$
]).pipe(
map(([clients, filter]) =>
clients.filter(state => state.firstName.toLowerCase().indexOf(filter.toLowerCase()) !== -1))
);
}
});
}
}
Alternate Implementation
I prefer using ViewChild because it keeps the event logic out of the template but if you don’t want to use ViewChild
, you could instead create a BehaviorSubject<string>
and then in your template, you could just set the value in your (ionChange)
event.
In your component code, you would assign the searchFilter$
variable like this
searchFilter$ = new BehaviorSubject<string>('');
and in your template, you would just bind the change event to update your Subject.
<ion-searchbar (ionChange)="searchFilter$.next($event.target.value)"></ion-searchbar>
Conclusion
You now have fully implemented search filtering functionality using only observables! As you can see, using observables make it very easy to follow the flow of your data.
Please let me know if you have any feedback or questions in the comments below!
Comments