Load large lists smoothly with Ionic, React, and Intersection Observer
TopVault allows collectors to see their entire collection on a single page. Collectors can also create large checklists or goals of collectibles and these can grow to over tens of thousands of entries.
This is a classic situation where a paginated return along with an 'Infinite Scroll' behavior provides a great user experience. Thankfully Ionic provides a lot of these tools out of the box. However, adding even a single page of content to the DOM can cause scroll and initial load issues and degrade the user experience. This post describes how TopVault solves these using a few off the shelf tools.
Initial Paged Experience
First let's ground on the initial experience when TopVault first implemented paginated lists. Each page contained 100 entries and the DOM would load these all at once, but the experience was not at all instant.
In this case Ionic's IonInfiniteScroll can help:
<IonInfiniteScroll threshold="600px" onIonInfinite={onInfiniteTrigger}>
<IonInfiniteScrollContent />
</IonInfiniteScroll>
And the body of onInfiniteTrigger can increase a visiblePageOffset state varialbe by a reasonable about such as 20. This way 100 items will be returned from an API call, but only 20 will be written to the DOM at a time, increasing as the user scrolls the list.
This behavior was simple, but still the DOM was slow the load and seen in this example.

Adding Intersection Observer
Intersection Observer is a DOM feature to detect if an element is observed in the view pane. It is commonly used for smoothing DOM loading and is a normal solution for this problem.
Adding an Intersection Observer to an Ionic app is very simple. But to optimize performance we need to overcome one challenge described in the next section.
Simply adding the Observer results in a IonList and IonItem structure like the following.
<IonList>
{cursor.items.map(entry => (
<RenderIfVisible key={entry.entryId}>
<EntryView entry={entry} />
</RenderIfVisible>
))}
{cursor !== undefined && cursor.items.length > 0 && (
<IonInfiniteScroll threshold="600px" onIonInfinite={onInfiniteTrigger}>
<IonInfiniteScrollContent />
</IonInfiniteScroll>
)}
</IonList>
The content of onInfiniteTrigger is simplified to only requesting the next page:
// Use TanStack Query's useInfiniteQuery...
const { fetchNextPage, hasNextPage, isFetchingNextPage, ... } = useInfiniteQuery<...>({...});
// Implement an onIonInfinite callback...
const onInfiniteTrigger = useCallback(
async (ev: InfiniteScrollCustomEvent) => {
if (hasNextPage && !isFetchingNextPage) {
await fetchNextPage();
}
await ev.target.complete();
},
[hasNextPage, isFetchingNextPage, fetchNextPage]
);
And the Intersection Observer logic is cleanly kept in the RenderIfVisible component.
RenderIfVisible
The TopVault implementation is a modified version of the react-render-if-visible. Though using the exact upstream component is recommended and was how TopVault worked for a while.
This results in a much better load experience, and with slow to medium scrolling has almost no tearing. In this case there shouldn't ever be tearing except that each entry in TopVault includes a preview image. If the collector's device (their web browser or mobile device) does not have the preview cached then TopVault fetches it just-in-time.
Notice the subtle image tearing when the scroll is fast in this example.

Preloading with Ionic
To remove tearing, we need the Intersection Observer to include a visible offset. This means the observation will happen before entering the view port. This is usually provided out of the box by supplying a visibleOffset parameter to RenderIfVisible.
In Ionic's case (and likely other SPAs) there is a scroll container that will set offset-y: hidden. This means the default observer behavior of using an offset to the view point does not work. The solution is to provide the offset relative to the scroll container, which is usually the IonContent component.
TopVault already has a React Context called PageProvider that allows accessing and updating parts of the IonPage. This was extended to provide a reference to the scroll container:
export function PageProvider({ pageId, children }: PageProviderProps) {
const pageRef = useRef<HTMLIonHeaderElement>(null);
const scrollRef = useRef<HTMLElement>(null);
useEffect(() => {
if (!pageRef.current) {
return;
}
const contentEl = pageRef.current.querySelector('ion-content');
contentEl
?.getScrollElement()
.then(contentScroll => {
scrollRef.current = contentScroll;
})
.catch(() => undefined);
}, [pageRef, scrollRef]);
return (
<PageContext.Provider value={{ pageRef, scrollRef }}>
<IonPage id={pageId} ref={pageRef}>
{children}
</IonPage>
</PageContext.Provider>
);
}
Then for any usage of RenderIfVisible an offset for the scroll container can be implemented with:
const { scrollRef } = usePage();
And:
<RenderIfVisible {...} root={scrollRef.current ?? null} visibleOffset={3000}>
<EntryView entry={entry} />
</RenderIfVisible>
In the case of TopVault's entries, which are usually 100-200px long, a value of 3000 as the offset allowed for extremely fast scrolling.
Now observer a completely smooth experience under extremely fast scrolling behavior.

TopVault: Tech Blog