Search & filter with RxJS

Bananica Bananica
5 min readApr 1, 2023

--

Querying, filtering and displaying data is such a common scenario in all apps. It’s also really simple to implement it in Angular, thanks to RxJS magic. I’ll share my approach here.

Side note

There’s this notion that RxJS is hard to learn. Personally, I haven’t had much problems with it, but JS/TS isn’t my first, nor primary language, in everyday development. These concepts are not hard, and that knowledge can also be translated elsewhere. I highly recommend learning it, even if Angular isn’t your primary choice. RxJS simlpy provides data types and functions to handle multiple events elegantly, and handling events is what we do 90% of the time. On user click, on mouse hover, on http request, on database crash, etc…

All of the above said, this isn’t RxJS intro, but I’ll give my best to describe every operator used in the following example. For a deep dive, I recommend this playlist. Official docs are good, but not noob friendly, since they focus a lot on technical stuff, and not the concepts.

Any data fetched from the backend, is usually stored in a service’s BehaviorSubject, so many components can access it. I’m skipping data fetching part, and just giving it a mocked seed:

@Injectable()
export class DataService {
// seed data
private seed = ['jaw', 'jar', 'toy', 'troy', 'hip', 'hop'];
private data$ = new BehaviorSubject(this.seed);

// expose Observable
data = this.data$.asObservable();

// enable adding, and emitting fresh dataset
addData(x: string) {
const newData = this.data$.getValue().concat(x);
this.data$.next(newData);
}
}

The idea is to print this array in some component, and ability to filter it based on user input. I’ll also add ability to append more stuff to the array. Component html:

<label>
Search:
<input #searchQuery type="text" (input)="onSearchUpdated(searchQuery.value)" />
</label>

<label>
Add:
<input #newItem type="text" (change)="onNameTyped(newItem.value)" />
</label>

<div *ngFor="let d of data$ | async">
{{ d }}
</div>

Two inputs. searchQuery will be used for filtering (on every keystroke), and newItem will add a string when Enter key is pressed. Lastly, we loop through available data, using an observable with async pipe. Now for the nitty-gritty stuff in the TS file:

export class FilterComponent implements OnInit {
constructor(private dataService: DataService) {}

data$ = new Observable<string[]>();
searchQuery$ = new BehaviorSubject<string>('');

ngOnInit(): void {
this.data$ = combineLatest([
this.searchQuery$,
this.dataService.data
])
.pipe(
map(([searchQuery, data]) => data.filter(x => x.includes(searchQuery)))
);
}

onSearchUpdated(searchQuery: string) {
this.searchQuery$.next(searchQuery);
}

onNameTyped(newItem: string) {
if(newItem.length === 0) {
return;
}

this.dataService.addData(newItem);
}
}

Breaking it down a few lines at the time:

constructor(private dataService: DataService) {}

data$ = new Observable<string[]>();
searchQuery$ = new BehaviorSubject<string>('');

Inject DataService via constructor, that much should be clear. But below that is data$ Observable and searchQuery$ BehaviorSubject. In this scenario, you don’t want to deal with actual values, only with Observable containers holding them. Let the async pipe to the rest. BehaviorSubject also implements IObservable interface, so it is also an Observable under the hood. The reason I’m using BehaviorSubject for searchQuery$lies in ngOnInit function. So let’s inspect that:

ngOnInit(): void {
this.data$ = combineLatest([
this.searchQuery$,
this.dataService.data
])
.pipe(
map(([searchQuery, data]) => data.filter(x => x.includes(searchQuery)))
);
}

There are 3 RxJS operators to worry about here: combineLatest, map, pipe.

combineLatest

An observable can be thought of as an event emitter in some way. combineLatest takes an array of observables. When all of them have values, and whenever any of them emits the new value, this function is triggered.

pipe

Think of it as a Unix pipe. It allows you to chain multiple functions, feeding output of the previous function, as an input to the next. Similar to how you chain functions in plain JS:

[1, 2, 3].filter(x => x % 2 === 0).map(x => x.toString())

We can write the code above written like this:

pipe(
[1,2,3],
(xs) => xs.filter(x => x % 2 === 0),
(xs) => xs.map(x => x.toString())
);

If we implement the pipe function:

const pipe = (x, ...fns) => {
return fns.reduce((currentVal, fn) => fn(currentVal), x);
}

map

Arrays and observables are both just data containers called monads. In each case, a map function takes that container, unwrap inner values, apply transformations to it, and wrap it back in the container. Array map, takes an array as input, and returns an array as a result. RxJS map does the same, but with an Observable.

Getting back on topic, with RxJS functions somewhat clarified, here’s the gist:

ngOnInit(): void {
// wait for any observable to change
this.data$ = combineLatest([
this.searchQuery$,
this.dataService.data
])
// pipe observables through none/one/many transform functions
.pipe(
// perform transformations on unwrapped data
map(([searchQuery, data]) => data.filter(x => x.includes(searchQuery)))
// data is wrapped back in an observable automatically
);
// results will be shown in the component,
// because we have an async pipe on the data$ observable
// to do the "unwrapping" for us
}

Double check that combineLatest again. If you recall my description above, it will trigger for the first time, when all observables passed to it emit something, and then, it will be subsequently trigger on every emission, from any of them. First time when all, after that when any. This is why I’ve set searchQuery$ to be BehaviorSubject. It must have initial value, so that combineLatest gets triggered, and it is an empty string, so nothing gets filtered out, yet.

Final two functions in the component, to hook up inputs:

// emit new searchQuery$ value to trigger combineLatest
onSearchUpdated(searchQuery: string) {
this.searchQuery$.next(searchQuery);
}

// add new data to service
onNameTyped(newItem: string) {
if(newItem.length === 0) {
return;
}

this.dataService.addData(newItem);
}

And finally the fruits of our labour:

filtering demo

Note two things. We get fresh data on every keystroke, because searchQuery$ emitted new value, and when jarvis was added to the DataService, it was also automatically filtered and displayed, because DataService data$ emitted a new value. You can achieve very complex filtering, simply by adding more BehaviorSubjects. Example:

this.data$ = combineLatest([
this.searchQuery$,
this.priceRange$,
this.itemType$,
this.dataService.data
])
.pipe(
map(([searchQuery, priceRange, itemType, data]) => {
return data.filter(x =>
x.name.includes(searchQuery) &&
(x.price >= priceRange.min && x.price <= priceRange.max) &&
x.type === itemType
);
})
);

Uhm…? Conclusion? Give RxJS a proper whirl. It ain’t rocket science, and it’s totally worth it.

Thanks for reading :)

--

--