React TS, Tanstack Query로 무한스크롤 구현하기

문다현·2024년 5월 16일
0
post-thumbnail

개발을 하던 도중, 그러한 뷰가 있었다.
카테고리를 선택하면 해당 카테고리에 맞는 글들이 밑에 주르륵 뜨는..!

현재는 글 수가 그렇게 많지 않아서 로딩되는 데 시간이 그리 오래 걸리지 않지만, 만약 데이터의 양이 방대해진다면, 그것을 한번에 불러온 다음에 띄우는 것에서 UX가 매우 저하할 것이다. 이러한 경우를 대비하여 무한스크롤을 도입하기로 하였다.

무한스크롤(Infinite Scroll)

웹사이트나 앱에서 사용되는 스크롤링 기술로, 사용자가 웹페이지를 스크롤하면 새로운 콘텐츠가 자동으로 동적으로 로드되는 방식을 말한다. 스크롤을 해야지만, 새로운 정보가 동적으로 불러와지기 때문에 초기 렌더링이 빠르다.

그리고 페이지네이션과 비교하였을때의 좋은 점은, 페이지 이동 없이 새로운 정보를 계속 볼 수 있다는 점이다!! 이러한 점 때문에 사용자 이탈율이 낮아지는 데 기여를 한다. 특히 작은 화면에서 버튼을 눌러 페이지 이동을 하는 것보다 무한스크롤이 유저 입장에서 훨씬 더 간편하여 모바일 뷰 웹,앱에서 많이 쓰는 기술이다.

나는 탄스택쿼리에서 지원하는 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,
})

옵션

  • 옵션은 useQuery와 동일하다. 다만, 밑의 내용들이 추가된다.

queryFn: (context: QueryFunctionContext) => Promise<TData>

  • 필수. 하지만 default 쿼리 함수가 정의되지 않았을때(defaultQueryFn)
  • 이 함수는 쿼리가 데이터를 요청할 때 쓴다.
  • QueryFunctionContext를 받는다
  • 프로미스를 반환받아야 한다( resolve data든, throw error든)

initialPageParam: TPageParam

  • 필수.
  • 첫번째 페이지를 패칭해올 때 쓰는 default 페이지 파라미터다.

getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => TPageParam | undefined | null

  • 필수
  • 만약 새로운 데이터가 들어오면, 이 함수는 마지막 페이지의 데이터 리스트와 모든 페이지의 배열과, pageParam에 대한 정보를 받는다.
  • 하나의 변수를 반환한다. 이 변수는 마지막 선택적 매개변수로서 쿼리 함수에 쓰이게 된다.
  • undefined나 null을 반환했다는 것은 더이상 다음 페이지가 없다는 뜻

getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => TPageParam | undefined | null

  • 만약 새로운 데이터가 들어오면, 이 함수는 첫번째 페이지의 데이터 리스트와 모든 페이지의 배열과, pageParam에 대한 정보를 받는다.
  • 하나의 변수를 반환한다. 이 변수는 마지막 선택적 매개변수로서 쿼리 함수에 쓰이게 된다.
  • undefined나 null을 반환했다는 것은 더이상 이전 페이지가 없다는 뜻

maxPages: number | undefined

  • infinite 쿼리 데이터를 저장할 최대 페이지 수.
  • 만약 최대 페이지 수에 도달하면, 새로운 페이지를 패칭해오려 할 때, 첫번째나 마지막 페이지가 없어지게 될 것이다.
  • undefined나 null이면, 페이지 수의 제한이 없다는 뜻
  • default는 undefined다.
  • maxPages 값이 0보다 클 경우에는, 양방향으로 페이지를 잘 패칭해올 수 있게 getNextPageParam과 getPreviousPageParam 잘 정의되어야한다.

반환 Returns

  • return 값 역시도 useQuery와 동일하지만, 밑의 내용이 추가되고 ,

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

profile
기록 남기기

3개의 댓글