I had to create a custom infinite scroll.
To keep this example simple:
- It should handle a "load more" action
- It should handle a "refresh" action (it should repeat all the async call to get fresh data)
- The current state should be preserved during the refresh
So I was looking for some articles on Google and it was a disaster.
Most articles promote a solution that includes a lot of boilerplate, with imperative code that is difficult to read.
Even the functionalities were very limited.There are some nice libraries that may be useful, but they should be used directly inside the template by using their component or directive.
The closest solutions that I found were using a pattern with RxJs
scan
orscanMap
. But again, it was not possible to extend the pattern to match my needs.
Angular Dev promotes the benefits of using Rxjs, but it looks like it is not mastered enough to be used for common cases. It is a little annoying for junior dev that need to implement such functionalities.
So I had to find a more powerful pattern that is more scalable.
Create an infinite scroll orchestrator
For this demonstration, I will share a solution that uses only RxJs, because I worked on a project that does not have Signals yet.
Use a powerful RxJs pattern
The solution that I have found is based on a reusable pattern that you should learn if you use RxJs. Here is a dedicated article about it RxJS > Signals ? Mon pattern préféré.
It looks like that:
The first part included in the merge declares all the sources and how it should affect the state (by returning a callback function that takes a current state as an argument).
The second part is the scan
RxJs operator that preserves the state and calls the "reducer" of the event emitted.
The third part is a startWith
that will enable it to emit an initial value before any source emits a value.
How can this base handle an infinite scroll pattern?
Creating an infinite scroll pattern that can scale
Now, it is relatively simple to adapt this pattern to handle infinite scroll, but the way to handle the refresh behavior is not common.
Instead of just making an async call and accumulating its result by using the scan, I will use the groupBy
RxJs operator (if you are not familiar with this operator, I wrote an article about it: Angular: Granular CRUD Request Status Tracking on a List (RxJs groupBy 🙏).
And it enables us to easily store the response per page.
Then, the final result will accumulate all the data by parsing each cached page response.
It is like creating a computed or derived result.
Furthermore, all my async calls use stateStream
(a custom operator available in the StackBlitz) that is similar to the Angular resource. It enables us to track the async request status.
Another main difference with RxJs solutions that you can find about infinite scroll is that the sources are preserved (one source is created per page thanks to the groupBy
).
Why is it important? Because we can use the power of the repeat
RxJs operator to trigger the refresh (just by adding one line).
Angular smart infinite scroll orchestrator pattern
So, here's my solution that satisfies my needs and even more.
private readonly page$ = new BehaviorSubject(1);
private readonly postsData$ = merge(
this.page$.pipe(
groupBy((page) => page),
mergeMap((page$) =>
getPosts(page$.key, this.batchSize).pipe(
repeat({
delay: () => this.refreshTrigger$,
}),
map((data) => ({ ...data, page: page$.key })),
map((statedPosts) => (state: PostsPerPage) => ({
...state,
[statedPosts.page]: {
...statedPosts,
result: statedPosts.isLoaded
? statedPosts.result
: // keep previous result during reloading
state[statedPosts.page]?.result ?? [],
},
}))
)
)
)
).pipe(
scan((acc, fn) => {
return (fn as any)(acc);
}, {} as PostsPerPage),
withLatestFrom(this.page$),
map(([data, page]: [PostsPerPage, number]) => {
const hasFirstPage = !!data[page];
const isFirstPageLoaded =
Object.keys(data).length > 0 &&
Object.values(data).some((statedData) => statedData.isLoaded);
const currentPage = data[page];
const hasMore =
currentPage?.isLoaded &&
!!currentPage?.result?.length &&
(currentPage?.result.length || 0) >= 3;
const concatenatedPosts = Object.values(data)
.map((statedData) => {
return statedData.result ?? [];
})
.flat();
return {
dataPerPage: data,
isFirstPageLoaded,
isLoading:
Object.values(data).some((statedData) => statedData.isLoading) ||
!hasFirstPage,
isLoaded: Object.values(data).some((statedData) => statedData.isLoaded),
hasMore,
result: this.removeDuplicatedPosts(concatenatedPosts),
};
}),
shareReplay({
refCount: true,
bufferSize: 1,
})
);
As you can see after the scan
operator, there is withLatestFrom
that I use to get the last page value (It could be retrieved by other way).
Then, there is a map
that I use to compute all properties that I need to use in my template.
Here you can play with it, thanks to StackBlitz.
The template was generated using Bolt
Conclusion
So, I think it is a smart way to deal with infinite scroll that can be adapted to specific needs without the need to use a library.
I think it scales well, because I had to implement another functionality and it was relatively easy to add it.
- I had to listen to a websocket and add incoming posts to the list displayed only if some conditions were validated.
I like this RxJs solution, but it is also possible to do it without RxJs, by just using Signal. I have already converted a similar pattern from RxJs to Signal. I may write an article about it. And it is something I will provide inside my tool @ng-query, that is a powerful tool to handle common complex UX with a simple DX.
I know my infinite scroll requirements were specific and may be different from yours. But I think, this pattern can help you build a robust solution.
Let me know what you think about this pattern and if you have questions, please write them in the comment below!
If you don’t know me, I’m Romain Geffrault, and I regularly share Angular/TypeScript/RxJs/Signal content. Check out my other articles and follow me on LinkedIn Romain Geffrault
Top comments (0)