useInfiniteQuery를 사용한 무한 스크롤 구현하기 (react-query)

계발자·2024년 4월 25일

무한스크롤 구현

패스트캠퍼스 강의를 수강하면서 진행했던 프로젝트에서 무한 스크롤을 구현했던 것을 정리해보려고 한다.

useInfiniteQuery란?

https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery#useinfinitequery

/* 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,
})

프로젝트에서 사용한 부분에 대해서만 간략히 알아보겠습니다.
returns
data : 서버에 요청해서 받아온 데이터

data.pages : 가져온 페이지들의 배열

data.pageParams : 페이지를 가져오기 위한 페이지 매개변수. 배열 형태

isFetching : 데이터를 가져오고 있는지 여부를 확인합니다(boolean).

fetchNextPage, fetchPreviousPage : 다음페이지, 이전페이지를 불러옵니다.

hasNextPage, hasPreviousPage : 다음 및 이전 페이지가 있는지 여부를 나타내는 boolean 값

isFetchingNextPage, isFetchingPreviousPage : 다음 및 이전 페이지의 데이터를 가져오는 동안 로딩 상태를 나타내는 boolean 값

option

queryKey : 쿼리를 구별하여 캐시를 관리하기위한 이름, key입니다.

queryFn : 쿼리가 데이터를 요청하는 데 사용할 함수, API입니다.

getNextPageParam, getPreviousPageParam : 다음 및 이전 페이지에 대한 매개 변수를 생성하는 함수


프로젝트에서 구현한 코드

// API 호출 코드
const fetchStores = async ({ pageParam = 1 }) => {
  const { data } = await axios("/api/stores?page=" + pageParam, {
    params: {
      limit: 10,
      page: pageParam,
      ...searchParams,
    },
  });
  
  return data;
};

// 무한 스크롤 구현 코드
const {
  data: stores,
  isFetching,
  fetchNextPage,
  isFetchingNextPage,
  hasNextPage,
  isError,
  isLoading,
} = useInfiniteQuery(["stores", searchParams], fetchStores, {
  getNextPageParam: (lastPage: any) =>
  lastPage.data?.length > 0 ? lastPage.page + 1 : undefined,
});

// 다음 API 호출 코드
const fetchNext = useCallback(async () => {
  const res = await fetchNextPage();
  if (res.isError) {
    console.log(res.error);
  }
}, [fetchNextPage]);

useEffect(() => {
  let timerId: NodeJS.Timeout | undefined;
  
  if (isPageEnd && hasNextPage) {
    timerId = setTimeout(() => {
      fetchNext();
    }, 500);
  }
  
  return () => clearTimeout(timerId);
}, [fetchNext, isPageEnd, hasNextPage]);

무한 스크롤 구현 코드를 살펴보면

// 무한 스크롤 구현 코드
const {
  data: stores,
  isFetching,
  fetchNextPage,
  isFetchingNextPage,
  hasNextPage,
  isError,
  isLoading,
} = useInfiniteQuery(["stores", searchParams], fetchStores, {
  getNextPageParam: (lastPage: any) =>
  lastPage.data?.length > 0 ? lastPage.page + 1 : undefined,
});

stores라는 key값으로 searchParams를 매개 변수로 담아 fetchStores함수를 호출하고 데이터를 받아옵니다.

// 다음 API 호출 코드
const fetchNext = useCallback(async () => {
  const res = await fetchNextPage();
  if (res.isError) {
    console.log(res.error);
  }
}, [fetchNextPage]);

useEffect(() => {
  let timerId: NodeJS.Timeout | undefined;
  
  if (isPageEnd && hasNextPage) {
    timerId = setTimeout(() => {
      fetchNext();
    }, 500);
  }
  
  return () => clearTimeout(timerId);
}, [fetchNext, isPageEnd, hasNextPage]);

일정 부분에 도달했을 때 setTimeout을 사용해 다음 페이지의 데이터를 받아오는 fetchNextPage함수를 useCallback함수를 통해 실행시킵니다.


export default function StoreListPage() {
  const ref = useRef<HTMLDivElement | null>(null);
  const pageRef = useIntersectionObserver(ref, {});
  const isPageEnd = !!pageRef?.isIntersecting;
  const searchValue = useRecoilValue(searchState);

  const searchParams = {
    q: searchValue?.q,
    district: searchValue?.district,
  };

  const fetchStores = async ({ pageParam = 1 }) => {
    const { data } = await axios("/api/stores?page=" + pageParam, {
      params: {
        limit: 10,
        page: pageParam,
        ...searchParams,
      },
    });

    return data;
  };

  const {
    data: stores,
    isFetching,
    fetchNextPage,
    isFetchingNextPage,
    hasNextPage,
    isError,
    isLoading,
  } = useInfiniteQuery(["stores", searchParams], fetchStores, {
    getNextPageParam: (lastPage: any) =>
      lastPage.data?.length > 0 ? lastPage.page + 1 : undefined,
  });

  const fetchNext = useCallback(async () => {
    const res = await fetchNextPage();
    if (res.isError) {
      console.log(res.error);
    }
  }, [fetchNextPage]);

  useEffect(() => {
    let timerId: NodeJS.Timeout | undefined;

    if (isPageEnd && hasNextPage) {
      timerId = setTimeout(() => {
        fetchNext();
      }, 500);
    }

    return () => clearTimeout(timerId);
  }, [fetchNext, isPageEnd, hasNextPage]);

  if (isError) {
    return (
      <div className="w-full h-screen mx-auto pt-[10%] text-red-500 text-center font-semibold">
        다시 시도해주세요
      </div>
    );
  }

  return (
    <div className="px-4 md:max-w-4xl mx-auto py-8">
      {/* search filter */}
      <SearchFilter />
      <ul
        role="list"
        className="divide-y divide-gray-100"
      >
        {isLoading ? (
          <Loading />
        ) : (
          stores?.pages?.map((page, index) => (
            <React.Fragment key={index}>
              {page.data.map((store: StoreType, i: any) => (
                <StoreList
                  store={store}
                  i={i}
                  key={i}
                />
              ))}
            </React.Fragment>
          ))
        )}
      </ul>
      {(isFetching || hasNextPage || isFetchingNextPage) && <Loader />}
      <div
        className="w-full touch-none h-10 mb-10"
        ref={ref}
      />
    </div>
  );
}
profile
3년차 닭띠 프론트엔드 개발자입니다.

0개의 댓글