RxJS: Using CombineLatest() + StartWith() to remove code duplication
Using CombineLatest and StartWith (and DebounceTime) RxJS operators to remove code complexity and duplication when fetching paged search results
05 June 2018 Update: Follow up post with further refactoring.
01 June 2018 Update: Updated code to RxJS6.
Scenario
In my Angular5 application I have a search results page that contains three components:
- Text-based filters component
- Paged results list
- Pagination component
After wiring up my code I ended up with something similar to below. This all worked fine, but as you can see it’s quite repetitive and not to mention hard to maintain.
results-list.component.html
<app-filters (filter)="onFilterUpdated($event)"></app-filters>
<app-results [items]="(results$ | async)?.items"></app-results>
<app-paginator (pageUpdated)="onPageUpdated($event)" [totalPages]="((results$ | async)?.totalItems) / pageSize"></app-paginator>
results-list.component.ts
results$: Observable<result[]>;
currentFilter: string = "Open";
currentPage: number = 1;
readonly pageSize = 25;
onFilterUpdated(filter: string){
this.currentFilter = filter;
this.results$ = this.searchService.performSearch(this.currentPage,
filter, this.pageSize);
}
onPageUpdated(page: number){
this.currentPage = page;
this.results$ = this.this.searchService.performSearch(page,
this.currentFilter,this.pageSize);
}
ngOnInit(){
this.results$ = this.searchService.performSearch(this.currentPage,
this.currentFilter, this.pageSize);
}
Problem: Violation of DRY principle. Adding any extra form of triggering the search, will repeat the search line this.results$ = this.searchService.performSearch(this.currentPage, this.currentFilter);
Problem: If I want to subscribe to the results, I need to repeat the subscription each time I set this.results$. Don’t forget the associated unsubscription too.
Solution: Enter the combineLatest operator
When any observable emits a value, emit the latest value from each. http://www.learnrxjs.io
Next, we need the switchMap operator to finish off the combined observable and switch to the search observable
Map to observable, complete previous inner observable, emit values.http://www.learnrxjs.io
results-list.component.ts
results$: Observable<result[]>;
@ViewChild('paginator') paginator: PaginationComponent;
@ViewChild('filters') filters: FilterComponent;
readonly pageSize = 25;
ngOnInit(){
const page$ = this.paginator.pageUpdated;
const filter$ = this.filter.filterUpdated;
this.results$ = combineLatest(page$, filter$, (p, f) => {return {page: p, filter: f}}).pipe(
switchMap(r => this.searchService.performSearch(r.page, r.filter, this.pageSize));
}
New Problem: This option doesn’t allow for an initial search on initialisation of the page.
Solution: enter the StartWith operator
Emit given value first.https://www.learnrxjs.io
results-list.component.ts
const page$ = this.paginator.pageUpdated.startWith(1);
const filter$ = this.filter.filterUpdated.startWith('');
New Problem: Each binding on the UI to result$ resulted in a seperate call to the search service (even if we add a debounce in).
Solution: Subscribe to result and store results locally
Problem: Page number should reset to 1 when the filter updates
Solution: Store the current page, bind to it on the paginator and raise the event whenever the value changes. note: I’m still unsure if I’m 100% happy with this option
results-list.component.ts
const page$ = this.paginator.pageUpdated.pipe(
startWith(1),
tap(x=> this.currentPage = 1));
const filter$ = this.filter.filterUpdated.pipe(
startWith(''),
tap(x => this.currentPage = x));
Bonus: Add a debounce to minimise extra calls to the service
Final code
results-list.component.html
<app-filters></app-filters>
<app-results [items]="items"></app-results>
<app-paginator [totalPages]="totalPages'></app-paginator>
results-list.component.ts
items: result[];
totalPages: number;
readonly pageSize = 25;
@ViewChild('paginator') paginator: PaginationComponent;
@ViewChild('filters') filters: FilterComponent;
ngOnInit(){
const page$ = this.paginator.pageUpdated.pipe(
startWith(1),
tap(x=> this.currentPage = 1));
const filter$ = this.filter.filterUpdated.pipe(
startWith(''),
tap(x => this.currentPage = x));
combineLatest(page$,filter$, (p,f) => { return {page: p, filter: f}}).pipe(
debounceTime(200),
switchMap(r => this.searchService.performSearch(r.page, r.filter)))
.subscribe(results => {
this.items = results.items;
this.totalpages = results.totalItems / this.pageSize;});
}
The benefits of this code are that any extra source of initiating the search can be added to the combineLatest with ease. The main downside is the added complexity in having the pagination component raise it’s updated event whenever the current page is updated, especially when it gets reset back to ‘1’ when the filter changes.
Update: See my next post on async bindings to remove this subscription
Although I’m by no means an expert in the world of reactive programming, and I’m sure there are many other ways to skin this cat. It’s exercises like this, that help to exercise my RxJS muscle and build new skills. Let me know if you have an alternative to this solution, I’m always keen to learn more skills.