When implementing infinite scroll in React projects, it’s often not enough to simply call fetchNextPage().
Sometimes, you need to separate data unit management from scroll event triggers for better scalability and maintainability.
In this post, we’ll explore a scalable infinite scroll hook architecture by combining @tanstack/react-query’s useInfiniteQuery with the IntersectionObserver API.
results array.For demonstration, we use PokeAPI.
The API follows a typical infinite scroll response structure:
type ResponseType<T> = {
results: T[]; // actual data array
next?: string; // API endpoint for the next page
};
useInfiniteApi.tsData fetching logic is separated into a useInfiniteApi hook.
Using proper generics ensures type safety.
import { GetNextPageParamFunction, InfiniteData, useInfiniteQuery } from '@tanstack/react-query';
type ResponseType<T> = {
results: T[];
next?: string;
};
type InfiniteApiProps<TData> = {
defaultPageParam: string;
queryKey: string[];
getNextPageParam: GetNextPageParamFunction<string, ResponseType<TData>>;
select?: (data: InfiniteData<ResponseType<TData>, string>) => InfiniteData<ResponseType<TData>, string>;
};
export const useInfiniteApi = <TData, TError = Error>({
defaultPageParam,
queryKey,
getNextPageParam,
select
}: InfiniteApiProps<TData>) => {
return useInfiniteQuery<
ResponseType<TData>,
TError,
InfiniteData<ResponseType<TData>, string>,
string[],
string
>({
queryKey,
queryFn: async ({ pageParam }) => {
const response = await fetch(pageParam);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
},
initialPageParam: defaultPageParam,
getNextPageParam,
select
});
};
useInfiniteDisplay.tsThe useInfiniteDisplay hook controls how many items are displayed and triggers fetching the next page based on scroll position.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { InfiniteData } from '@tanstack/react-query';
type InfiniteDisplayProps<TData> = {
data?: InfiniteData<{ results: TData[] }, string>;
isFetchingNextPage: boolean;
fetchNextPage: () => Promise<unknown>;
initialCount: number;
hasNextPage: boolean;
};
export const useInfiniteDisplay = <TData>({
data,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
initialCount
}: InfiniteDisplayProps<TData>) => {
const [displayCount, setDisplayCount] = useState(initialCount);
const observerRef = useRef<HTMLDivElement | null>(null);
const displayResult = useMemo(() => {
if (!data?.pages) return [];
const totalResult = data.pages.flatMap(page => page.results);
return totalResult.slice(0, displayCount);
}, [data?.pages, displayCount]);
const handleObserver = useCallback(
async (entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
if (entry.isIntersecting) {
const resultLength = data?.pages.flatMap(page => page.results).length || 0;
if (displayCount < resultLength) setDisplayCount(prev => prev + initialCount);
if (displayCount >= resultLength && hasNextPage && !isFetchingNextPage) {
await fetchNextPage();
}
}
},
[data?.pages, displayCount, hasNextPage, isFetchingNextPage, fetchNextPage, initialCount]
);
useEffect(() => {
const observer = new IntersectionObserver(handleObserver, { root: null, rootMargin: '20px', threshold: 0.1 });
if (observerRef.current) observer.observe(observerRef.current);
return () => observer.disconnect();
}, [handleObserver]);
return { displayResult, observerRef } as const;
};
displayResult contains only the data to render, and observerRef is attached to the element that triggers fetching the next page.
import { useInfiniteApi } from './hooks/useInfiniteApi';
import { useInfiniteDisplay } from './hooks/useInfiniteDisplay';
const defaultPageParam = 'https://pokeapi.co/api/v2/pokemon?limit=100';
type DisplayItemType = { name: string; url: string };
function App() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteApi<DisplayItemType>({
defaultPageParam,
queryKey: ['poke-list'],
getNextPageParam: lastPage => lastPage.next,
});
const { displayResult, observerRef } = useInfiniteDisplay<DisplayItemType>({
data,
fetchNextPage,
hasNextPage,
initialCount: 10,
isFetchingNextPage
});
return (
<>
<ul>
{displayResult.map(item => <li key={item.url}>{item.name}</li>)}
</ul>
<div ref={observerRef}></div>
</>
);
}
export default App;
This architecture can be reused across different backends as long as the data structure is consistent:
type ResponseType<T> = {
results: T[]; // each data item
next?: string; // next page API URL
};
results is an ObjectIf the backend returns results as an object:
type ResponseType<T> = {
results: { [key: string]: T[] }
next?: string
}
You only need to modify the merge logic inside useInfiniteDisplay:
const displayResult = useMemo(() => {
if (!data?.pages) return [];
const totalResult = data.pages.flatMap(page => {
const resultMap = new Map();
Object.entries(page.results).forEach(([key, value]) => resultMap.set(key, value));
return Array.from(resultMap.values()).flat();
});
return totalResult.slice(0, displayCount);
}, [data?.pages, displayCount]);
As long as the data structure is clearly defined, the combination of IntersectionObserver + useInfiniteQuery can satisfy most infinite scroll requirements.