개발을 하던 도중, 그러한 뷰가 있었다.
카테고리를 선택하면 해당 카테고리에 맞는 글들이 밑에 주르륵 뜨는..!
현재는 글 수가 그렇게 많지 않아서 로딩되는 데 시간이 그리 오래 걸리지 않지만, 만약 데이터의 양이 방대해진다면, 그것을 한번에 불러온 다음에 띄우는 것에서 UX가 매우 저하할 것이다. 이러한 경우를 대비하여 무한스크롤을 도입하기로 하였다.
웹사이트나 앱에서 사용되는 스크롤링 기술로, 사용자가 웹페이지를 스크롤하면 새로운 콘텐츠가 자동으로 동적으로 로드되는 방식을 말한다. 스크롤을 해야지만, 새로운 정보가 동적으로 불러와지기 때문에 초기 렌더링이 빠르다.
그리고 페이지네이션과 비교하였을때의 좋은 점은, 페이지 이동 없이 새로운 정보를 계속 볼 수 있다는 점이다!! 이러한 점 때문에 사용자 이탈율이 낮아지는 데 기여를 한다. 특히 작은 화면에서 버튼을 눌러 페이지 이동을 하는 것보다 무한스크롤이 유저 입장에서 훨씬 더 간편하여 모바일 뷰 웹,앱에서 많이 쓰는 기술이다.
나는 탄스택쿼리에서 지원하는 useInfiniteQuery 훅을 써서 구현해보겠다.
다음은 useInfiniteQuery의 기본 구조이다.
const {
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
...result
} = useInfiniteQuery({
queryKey,
queryFn: ({ pageParam }) => fetchPage(pageParam),
initialPageParam: 1,
...options,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.nextCursor,
getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) =>
firstPage.prevCursor,
})
옵션
queryFn: (context: QueryFunctionContext) => Promise<TData>
initialPageParam: TPageParam
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => TPageParam | undefined | null
getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => TPageParam | undefined | null
maxPages: number | undefined
반환 Returns
data.pages: TData[]
모든 페이지를 담고있는 배열
data.pageParams: unknown[]
모든 페이지 매개변수를 담고있는 배열
isFetchingNextPage: boolean
fetchNextPage로 다음 페이지를 가져오고 있을 때 true
isFetchingPreviousPage: boolean
fetchPreviousPage로 이전 페이지를 가져오고 있을 때 true
fetchNextPage: (options?: FetchNextPageOptions) => Promise<UseInfiniteQueryResult>
다음 페이지를 불러오는 함수.
options.cancelRefetch: boolean
true일 경우, fetchNextPage는 이전 호출이 성공했던 실패했던, 반복적으로 fetchPage을 발생시킬것이다.
과거 호출의 결과는 무시된다
false일 경우, fetchNextPage는 첫번째 호출이 성공할 때까지 아무 일도 하지 않는다.
default는 true
fetchPreviousPage: (options?: FetchPreviousPageOptions) => Promise<UseInfiniteQueryResult>
이전 페이지를 불러오는 함수
This function allows you to fetch the previous "page" of results.
options.cancelRefetch: boolean
true일 경우, fetchPreviousPage는 이전 호출이 성공했던 실패했던, 반복적으로 fetchPage을 발생시킬것이다.
과거 호출의 결과는 무시된다
false일 경우, fetchPreviousPage는 첫번째 호출이 성공할 때까지 아무 일도 하지 않는다.
default는 true
hasNextPage: boolean
불러올 다음 페이지가 있을경우 true
getNextPageParam으로 알 수 있음
hasPreviousPage: boolean
불러올 이전 페이지가 있을 경우 true
getPreviousPageParam으로 알 수 있음
isRefetching: boolean
background refetch가 진행되고 있을 때 true.
(initial pending이나 다음/이전 페이지를 가져올때를 포함되지 않는다)
isFetching && !isPending && !isFetchingNextPage && !isFetchingPreviousPage 와 같은 이미라고 생각하면 된다
fetchNextPage와 같은 명령형 fetch 호출은 기본 refetch 동작을 방해하여 오래된 데이터를 초래할 수도 있다는 점을 유의하자.
유저 액션에 대한 응답으로 호출하거나, hasNextPage && !isFetching와 같은 조건을 추가하자.
일단은 Load More 버튼을 누르면 데이터가 더 뜨게 구현을 해보자!
나는 한 페이지당 5개의 데이터가 뜨게끔 구현할 것이다.
기존의 useQuery로 데이터 패칭을 해오는 코드이다.
//기존
export const useArticleList = (topicId: string) => {
const { data, isLoading, isError, error } = useQuery({
queryKey: [QUERY_KEY_GROUPFEED.getArticleList, topicId],
queryFn: () => fetchArticleList(topicId),
enabled: !!topicId,
});
const postListData = data && data.data.postList;
return { postListData, isLoading, isError, error };
//바뀐 코드
export const useArticleList = (topicId: string) => {
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: [QUERY_KEY_GROUPFEED.getArticleList, topicId],
queryFn: ({ pageParam }) => fetchArticleList(topicId, pageParam),
enabled: !!topicId,
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
return lastPage?.length == 0 || !lastPage || lastPage?.length < 5
? undefined
: allPages.length + 1;
},
});
return { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage };
};
getNextPageParam에서는 lastPage와 allPages를 이용하여 만약 다음 페이지가 없는 경우는 undefined를 반환하게, 다음페이지가 있는 경우에는 allPages.length + 1를 반환하게 가공하였다.
바뀐 코드에서 주목해야 하는 부분은 아래와 같다.
//기존 코드
export const fetchArticleList = async (topicId: string) => {
try {
const response = await client.get<ArticleListPropTypes>(`/api/topic/${topicId}`);
return response.data;
} catch (error) {
console.error('에러:', error);
}
};
//바뀐 코드
export const fetchArticleList = async (topicId: string, pageParam: number) => {
try {
const response = await client.get<ArticleListPropTypes>(`/api/topic/${topicId}`);
const postList = response.data?.data.postList || [];
const startIndex = pageParam === 1 ? 0 : (pageParam - 1) * 5 ;
const endIndex = Math.min(startIndex + 5, postList.length);
return postList.slice(startIndex, endIndex);
} catch (error) {
console.error('에러:', error);
}
};
배열을 slice하여 한번에 5개의 데이터를 반환하게 가공했다.
const { fetchNextPage, data, hasNextPage, isFetchingNextPage } = useArticleList(selectedTopicId || '');
{hasNextPage && <button onClick={() => fetchNextPage()}> Load More</button>}
이제 useInfiniteQuery를 이용하여 만든 커스텀훅인 useArticleList를 이용하여 데이터를 패칭해오고, 만약 다음 페이지가 존재할 경우에만 Load More 을 클릭하여 새로운 데이터를 패칭해올 수 있게끔 구현한다.
결과는 이런식으로 뜬다
이제 스크롤이 맨 밑임을 감지하여 자동으로 새로운 데이터를 불러오게끔 하자!!
이는 useRef를 사용하면 된다.
const bottomOfListRef = useRef<HTMLDivElement>(null);
const handleScroll = () => {
if (bottomOfListRef.current) {
const isBottom = bottomOfListRef.current.getBoundingClientRect().bottom <= window.innerHeight;
if (isBottom && hasNextPage) {
fetchNextPage();
}
}
};
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [handleScroll]);
{isFetchingNextPage && <Loading />}
그러고 데이터를 패칭할 동안은 로딩창을 보여주면 끝~!
https://designkits.co.kr/blog/web-terminology/Infinite-Scroll
https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery