사용자가 페이지를 보는 상황에서 화면을 스크롤해 일정 수준에 도달하면 새로운 데이터를 호출하여 자연스럽게 보여주는 방식이다. 더 좋은 사용자 경험을 제공하는 방법 중 하나이며 특히 모바일 이용자들한테 적용하기 좋은 방법이라고 생각한다. 만약 단점이 있다면 특정 항목을 검색할 때 조금 까다로울 수 있다.
무한 스크롤을 구현하기 위해서 라이브러리를 이용하면 쉽지만 간단한 원리를 파악하기 위해 Intersection Obeserver API를 활용할 것이다. 이는 관찰하고 싶은 요소를 target으로 설정하면 해당 observer가 target이 상위 요소 혹은 viewpoint와 교차가 발생하는지 비동기적으로 관찰한다.
꼭, Intersection Observer가 아니여도 addEventListener의 scroll 이벤트를 활용하여 구현할 수 있지만 이는 콜백함수를 수천번 이상의 호출을 동기적으로 실행해 메인 스레드에 큰 부하를 줄 수 있다고 한다. 또한, getBoundingClientRect 함수를 사용하여 특정 지점을 관찰하는데 리플로우 현상이 발생한다는 단점이 있어 이런 사항을 개선한 Intersection Observer로 구현하는 것에 추천한다.
React Query에서 useInfiniteQuery Hook를 제공해 무한 스크롤을 쉽게 구현할 수 있다. useQuery 처럼 key, fetch 함수, 옵션을 넣어 사용할 수 있지만 옵션 중에서 getNextPageParam를 추가로 알아야 한다. 또한, data의 반환 값도 다르게 pages 및 pageParams로 준다.
즉, pageParams는 페이지들을 가져오는데 필요한 page params가 담겨져 있고, pages는 fetch한 페이지들이 담겨져 있다.
// https://tanstack.com/query/v4/docs/react/reference/useInfiniteQuery
const {
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
...result
} = useInfiniteQuery({
queryKey,
queryFn: ({ pageParam = 1 }) => fetchPage(pageParam),
...options,
getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
})
getNextPageParam는 콜백함수이며 undefined가 아닌 다른 값으로 반환하면 다음 페이지가 있다는 것을 인지하여 hasNextPage 값이 true로 된다.
데이터는 주석에 적혀진 사이트에서 제공하는 Fake API를 사용했다. 출력 형태는 아래와 같다.
// Docs: https://dummyjson.com/docs/products
// GET, https://dummyjson.com/products
{
"products": [
{
"id": 1,
"title": "iPhone 9",
"description": "An apple mobile which is nothing like apple",
"price": 549,
"discountPercentage": 12.96,
"rating": 4.69,
"stock": 94,
"brand": "Apple",
"category": "smartphones",
"thumbnail": "...",
"images": ["...", "...", "..."]
},
{...},
{...},
{...}
// 30 items
],
"total": 100,
"skip": 0,
"limit": 30
}
제가 생각한 방식은 아래 total, skip, limit 값을 통해서 다음 페이지의 여부를 결정 지었습니다.
export const DATA_LIMIT = 10;
export const getPosts = async ({ pageParam = 0 }) => {
const response = await fetch(
`https://dummyjson.com/products?limit=${DATA_LIMIT}&skip=${
pageParam * DATA_LIMIT
}`
);
return response.json();
};
const App = () => {
const [target, setTarget] = useState(null);
const {
data,
error,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
} = useInfiniteQuery(['posts'], getPosts, {
getNextPageParam: (lastPage, allPages) => {
const { total, skip, limit } = lastPage;
const currentPage = allPages.length - 1;
return total >= skip + limit * 2 ? currentPage + 1 : undefined;
},
});
const onIntersect = async ([entry], observer) => {
if (entry.isIntersecting && hasNextPage) {
observer.unobserve(entry.target);
await fetchNextPage();
observer.observe(entry.target);
}
};
useEffect(() => {
let observer;
if (target) {
observer = new IntersectionObserver(onIntersect, { threshold: 0.2 });
observer.observe(target);
}
return () => observer && observer.disconnect();
}, [target]);
if (isFetching && !isFetchingNextPage) {
return <div>fetching</div>;
}
if (error) {
return <div>error</div>;
}
return (
<div>
{data.pages.map((group, idx) => (
<React.Fragment key={idx}>
{group.products.map(({ id, title, price, stock, images }) => (
<div key={`product_${id}`}>
<img src={images[0]} alt="images" width={100} height={100} />
<p>{title}</p>
<p>재고: {stock}</p>
<p>가격: ${price}</p>
</div>
))}
</React.Fragment>
))}
<div ref={setTarget}>
{hasNextPage ? '다음 아이템 불러오기' : '마지막 아이템'}
</div>
</div>
);
};
export default App;
핵심은 observer가 target을 viewpoint 내에 마주칠 때 hasNextPage가 true일 경우 새로운 페이지 데이터를 호출하는 식이로 이해하면 된다.