이 글은 React Query V5 를 이용하여 무한 스크롤을 구현하는 방법이다.
React Query V4는 initialPageParam 이 없어 pageParam에 직접 initialPageParam을 설정해주어야 하며, 이 외의 개념은 동일함.
목록 데이터를 불러올 때 무한스크롤을 사용하는 경우가 종종 있다.
프로젝트에 React Query를 도입했다면, useInfiniteQuery
Hook을 사용하면 큰 도움이 된다.
initialPageParam
: 제일 처음 API를 호출할 때 queryFn
이 받을 파라미터의 값이다. GET 요청인 경우 pageParam
에 API URL을 전달하고, queryFn
에서 pageParam
파라미터를 받아 적절하게 API를 요청한다.
getNextPageParam
: queryFn
이 실행되어 Promise의 resolve
값을 받고 나면 getNextPageParam
함수가 실행된다.
이 함수의 인자는 바로 전에 호출된 queryFn
의 resolve
응답 값이다. (Promise를 기다려서 받은 응답 값이라는 뜻임, 아래 코드 예시에서의 lastPage
)
이 파라미터 값을 이용해서 앞으로 호출할 수 있는 데이터가 더 남았는지 안남았는지를 판단해야 한다.
남아있는 데이터가 없으면 getNextPageParam
이 undefined
를 리턴해야 한다.
남아있는 데이터가 있으면 다음에 실행될 queryFn
에 전달할 pageParam
을 리턴해야 한다.
import { useInfiniteQuery } from "@tanstack/react-query";
import httpClient from "apis/networks/HttpClient";
import queryKeyFactory from "apis/query_config/queryKeyFactory";
const LIMIT = 10;
export const useGetComment = (articleId: number) => {
const initialUrl: string = `/api/v1/article/${articleId}/comments?limit=${LIMIT}`;
const {
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isFetching,
isError,
data,
} = useInfiniteQuery({
queryKey: queryKeyFactory.comment({ articleId: articleId }).queryKey,
queryFn: ({ pageParam }): Promise<GetCommentResType> => {
return httpClient.get(pageParam);
},
initialPageParam: initialUrl,
getNextPageParam: (lastPage) => {
if (!lastPage.data.cursor && typeof lastPage.data.cursor !== "number") {
return undefined;
} else {
return `api/v1/article/${articleId}/comments?cursor=${lastPage.data.cursor}&limit=${LIMIT}`;
}
},
});
}
IntersectionObserver
등을 이용해서 무한 스크롤의 트리거를 만들어주어야 한다.
무한스크롤 이벤트 트리거가 발생했을 때, useInfiniteQuery
Hook의 hasNextPage
값이 true
이면 fetchNextPage
를 호출하면 된다.
hasNextPage
값은 useInfiniteQuery
Hook의 getNextPageParam
함수의 결과가 undefined
일 때 false
가 된다.
useInfiniteQuery
에서 isLoading
은 queryFn
이 처음 로드될 때 true
이며, isFetcing
은 첫 로드 이후 다음 페이지를 로드할 때 true
가 된다.
useInfiniteQuery
의 data
는 이중 배열 구조이다.
data
에는 pages
배열이 있으며, queryFn
의 결과 한 개가 pages
의 배열 아이템이 된다. 그래서 화면에 무한 스크롤 아이템을 랜더링 할 때는 map
을 이중으로 돌려야 한다.
const Comment = ({ articleId }: { articleId: string }) => {
const observer = useRef<IntersectionObserver | null>(null);
const lastItemRef = useRef<HTMLDivElement>(null);
const { data, isLoading, isError, hasNextPage, fetchNextPage } =
useGetComment(Number(articleId));
useEffect(() => {
const handleObserver = (entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
if (entry.isIntersecting && hasNextPage) {
fetchNextPage();
}
};
const options: IntersectionObserverInit = {
root: null, // 뷰포트를 기준으로 감지
rootMargin: "0px",
threshold: 1.0,
};
observer.current = new IntersectionObserver(handleObserver, options);
const lastItem = lastItemRef.current; // ref 값을 로컬 변수로 저장
if (lastItem) {
observer.current.observe(lastItemRef.current);
}
return () => {
if (observer.current && lastItem) {
observer.current.unobserve(lastItem); // 컴포넌트 언마운트 시 관찰 종료
}
};
}, [hasNextPage, fetchNextPage]);
return (
<div className={classes.comment_list}>
{!isLoading && data?.pages[0].data.items.length === 0 && (
<div className={classes.no_comment}>아직 댓글이 없습니다.</div>
)}
{!isLoading &&
!isError &&
!!data &&
data.pages.map((page) =>
page.data.items.map((commentItem: CommentItemType) => {
return (
<CommentItem
key={commentItem.commentId}
commentItem={commentItem}
/>
);
}),
)}
</div>
<span ref={lastItemRef} />
</div>
);
}