We want to deepen our knowledge about higher order observables, by getting familiar with the exhaustMap
operator.
We will use it in order to implement an infinite scroll solution that paginates http calls on scroll events.
Our trigger is already implemented and ready to use. The ElementVisibilityDirective
emits an event everytime it
is visible to the user, indicating we've reached the bottom of the list and we should start fetching a new page.
As we don't want to over-fetch or fetch multiple pages at once, exhaustMap
is the operator of choice. It drops
consequent requests as long as we have a request inflight.
The infinite scrolling should be part of the MovieListPageComponent
s movie fetching logic.
First, add a trigger that should kick off the pagination event. Create a local paginate$: Subject<void>
and bind it
to the (elementVisible)
output event of the ElementVisibildityDirective
.
MovieListPageComponent paginate$ trigger
// movie-list-page.component.ts
readonly paginate$ = new Subject<void>();
<!-- movie-list-page.component.html -->
<movie-list
*ngIf="movies && movies.length > 0; else: elseTmpl"
[movies]="movies">
</movie-list>
<!-- use (elementVisible) here -->
<div (elementVisible)="paginate$.next()"></div>
Cool, now let's implement the actual pagination logic.
For our use case, please implement a function paginate(requestFn: (page: number) => Observable<TMDBMovieModel[]>): Observable<TMDBMovieModel[]>
.
The paginate
method should take a function that resolves a number input into Observable<TMDBMovieModel[]>
as input.
This is required as our component is responsible for fetching data from different services, depending on the route we are at. The input is the function to the service that passes the current page to fetch.
paginate skeleton
// movie-list-page.component.ts
private paginate(
requestFn: (page: number) => Observable<TMDBMovieModel[]>
): Observable<TMDBMovieModel[]> {
/* implementation happens here */
}
You can already go ahead and use the pagination function where it should be. We want to replace the movie fetching
logic within the movie$
Observable. Instead of returning the service call directly, we call the paginate
method and pass the function to it.
paginate usage
// movie-list-page.component.ts
movies$ = this.activatedRoute.params.pipe(
switchMap(params => {
if (params['category']) {
/* add paginate here 👇 */
return this.paginate((page) =>
this.movieService.getMovieList(params['category'], page)
);
} else {
/* add paginate here 👇 */
return this.paginate((page) =>
this.movieService.getMoviesByGenre(params['id'], page)
);
}
}
)
);
Now, implement the core logic of the pagination. We want to subscribe to the paginate$
trigger and exhaustMap
to the
given requestFn
input.
In order to accumulate the paged results into a single array, we can use a local cache.
pagination core logic
// movie-list-page.component.ts
private paginate(
requestFn: (page: number) => Observable<TMDBMovieModel[]>
): Observable<TMDBMovieModel[]> {
// local array to store all movies
let allMovies: TMDBMovieModel[] = [];
return this.paginate$.pipe(
exhaustMap((v, i) =>
// call requestFn with the page parameter, use the index from `exhaustMap`
// as the index is not 0 based
requestFn(i + 1).pipe(
map((movies) => [...allMovies, ...movies])
)
),
tap(movies => allMovies = movies)
);
}
Open the movie list in your browser and see if your pagination is properly working.
.... You've probably noticed that the list is entirely empty. The reason for it is the paginate$
Observable
is not emitting an initial event. Go ahead and introduce the startWith(void 0)
operator in order to kick off
the pagination process immediately on subscription.
pagination full solution
// movie-list-page.component.ts
private paginate(
requestFn: (page: number) => Observable<TMDBMovieModel[]>
): Observable<TMDBMovieModel[]> {
// local array to store all movies
let allMovies: TMDBMovieModel[] = [];
return this.paginate$.pipe(
startWith(void 0),
exhaustMap((v, i) =>
// call requestFn with the page parameter, use the index from `exhaustMap`
// as the index is not 0 based
requestFn(i + 1).pipe(
map((movies) => [...allMovies, ...movies])
)
),
tap(movies => allMovies = movies)
);
}
Try and replace the local allMovies
array with a cleaner solution by using the scan
operator.
paginate with scan
// movie-list-page.component.ts
private paginate(
requestFn: (page: number) => Observable<TMDBMovieModel[]>
): Observable<TMDBMovieModel[]> {
return this.paginate$.pipe(
startWith(void 0),
exhaustMap((v, i) => requestFn(i + 1)),
scan((allMovies, movies) => ([
...allMovies,
...movies
]), [] as TMDBMovieModel[])
);
}