본문 바로가기
FrontEnd/React.js

custom Infinite Scrolling hooks (with tanstack Query in Next.js)

by 위그든씨 2024. 9. 3.

오랜만에 무한 스크롤링 구현하면서 기록을 남겨봄

두가지 커스텀 훅을 만들 예정

1. 리액트 쿼리에서 제공하는 useInfiniteQuery를 활용한 커스텀 훅 ->이하 useQuery( 아래는 반환 값 ) -> useInfiniteQuery 

{data, fetchNextPage, hasNextPage, isFetchingNextPage, status}

 

2.  바닥 ref가 InView인지 확인하여 조건 만족시 fetchNextPage 발동 할 useInfiniteScroll 

 

1. useQuery 매개변수로는 아래 요인들 ( 카테고리에서 날짜,지역,태그에 따라 보여지는 게시물들이 다름)

{category,searchDate,searchRegions, searchTags}

일단 react-query 에서 제공 받을 값들부터 알아보자 

 

  • data:
    • 서버로부터 받은 응답 데이터를 포함하는 객체
    • *** 무한 쿼리의 경우, 이 객체는 pages 배열을 포함 
    • *** 각 page는 개별 요청에 대한 응답 데이터
      • 예: data.pages[0]은 첫 번째 페이지의 데이터
  • fetchNextPage:
    • 다음 페이지의 데이터를 가져오는 ***함수
    • 사용자가 "더 보기" 버튼을 클릭하거나 스크롤의 끝에 도달했을 때 호출
    • 이 함수를 호출하면 새로운 페이지의 데이터가 ****(기존 data.pages 배열에 추가)
  • hasNextPage:
    • 불리언 값으로, ***더 가져올 데이터가 있는지를 나타냄.
    • true면 더 많은 페이지가 있다는 뜻이고, false면 모든 데이터를 가져왔다는 뜻
    • 이 값은 주로 getNextPageParam 옵션의 반환값에 따라 결정
  • isFetchingNextPage:
    • 불리언 값으로, 현재 다음 페이지를 ***가져오고 있는 중인지를 판단
    • true일 때 로딩 인디케이터를 표시하는 데 유용
  • status:
    • 쿼리의 현재 상태를 나타내는 문자열
    • 'loading': 초기 데이터를 가져오는 중 -> 맨 처음 데이터 불러올 때라는 뜻
    • 'pending'
      • Tanstack Query v5에서 도입된 새로운 상태
      • loading을 대체
      • 쿼리가 아직 데이터를 가져오지 않았거나 가져오는 중임을 나타냄
      • 캐시된 데이터가 있더라도, 백그라운드에서 refetch가 진행 중이면 'pending' 상태가 될 수 있음
    • 'error': 데이터 가져오기 실패
  • isFetchingNextPage VS status.pending
    • isFetchingNextPage: 초기 데이터 **이후 추가 페이지를 가져올 때 사용
    • status pending: 초기 데이터를 가져올 때 또는 전체 쿼리가 다시 실행될 때 사용

아래는 인피니티쿼리훅에 넘겨줄 인자들 

useInfiniteQuery({
      queryKey: [category, searchDate, searchRegions, searchTags],
      queryFn: ({ pageParam }) => getCategoryList(pageParam),
      initialPageParam: 0,
      getNextPageParam: (lastPage) => lastPage?.nextCursor,
});

 

  1. queryKey:
    • 쿼리를 식별하는 고유한 키
    • 배열 형태로 지정
    • 이 키를 기반으로 쿼리 결과가 캐시되고 관리됨. (쿼리 메모리에서 끄집어온다능)
  2. queryFn:
    • 데이터를 가져올 패칭 함수
    • 이 함수는 pageParam을 인자로 받아 페이지네이션을 구현함 -> 위에서 언급한 data.pages[num]
  3. initialPageParam:
    • 첫 번째 페이지를 가져올 때 사용할 초기 페이지 파라미터 값
    • 이 값은 queryFn의 첫 번째 호출 시 pageParam으로 전달
    • default undefined
  4. getNextPageParam:
    • 다음 페이지의 파라미터를 결정하는 함수
    • 현재 페이지 데이터와 모든 페이지 데이터를 인자로 받음
    • 이 함수의 반환값이 다음 queryFn 호출의 pageParam으로 사용됨
    • undefined를 반환하면 더 이상 페이지가 없다고 간주

===>

  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
    useInfiniteQuery({
      queryKey: [category, searchDate, searchRegions, searchTags],
      queryFn: ({ pageParam }) => getCategoryList(pageParam),
      initialPageParam: 0,
      getNextPageParam: (lastPage) => lastPage?.nextCursor,
    });

  return {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
  };

data => 화면에 보여줌

fetchNextPage => 무한 스크롤을 통해 바닥 찍으면 발동 (단, hasNextPage가 true일 시 ) 

isFetchingNextPage => 바닥을 찍고 추가로 데이터를 가져오는 상황이라면 loader 애니메이션을 보여줄 거임

status => error / pending을 통해 에러 또는 초기 로딩시 스켈레톤 보여줄 용도

 

2.  바닥 ref가 InView인지 확인하여 조건 만족시 fetchNextPage 발동 할 useInfiniteScroll 

1. bottomRef 를 따로 선언하여 쿼리 데이터중 가장 마지막 요소에게 할당

2. 옵저버를 통해 inview 이면 fetchNextPage() 실행 

 *** 트러블 슈팅:

  • 이상하게 클라이언트가 동작(이벤트 발생 같은)이후에야 해당 inView가 관찰됨 
    • page.tsx에서 'use client'를 선언하는 것이 클라이언트 렌더링 버그가 생기나 해서 단위 컴포넌트로 쪼갠 후 그 안에서 클라이언트 렌더링을 명시해봄 -> 이전과 똑같음
    • next.js만의 무슨 버그인건가? -> 임시 페이지 생성 후 관찰 했을 떄는 잘됨 
    • 그렇다면 ref 할당 시점이 늦어지는건가 싶어서 하이드레이션 지연 시켜질만한 요소들 찾아봄 -> useQuery 훅 동작을 제외했더니 관찰 잘 됨 -> 근데 이 훅 사용자체가 렌더링에 문제를 만들어 ref 할당이 늦어지고 inView 관찰이 안되는건 말이 안됨 =====>  status에 따라 아래와 같이 코드를 짰을 때 관찰이 안됐던 거였음 (왜냐면 아직 서버에 직접 연결안 한 상태라 껄껄)
{status === "pending" ? (
        <CategoryList.skeleton />
      ) : (
        <CategoryList
          bottomRef={bottomRef}
          categoryType={(category as "popup") || "exhibition"}
        />
      )}
  • 마지막 카드가 시야에 들어오면 fetchNextPage를 실행시키게 짤려는데 관찰되는 마지막 카드가 처음에 할당된 ref로 고정박혀짐
    • 화면에 표출되는 카드에 useRef로 할당된 ref값을 넘겨준 뒤 이것이 시야에 들어오면 data 패칭이 일어나서 카드들은 증가할 것. bottomRef를 length-1 === idx인 카드에 할당해서 리렌더링시 자동으로 bottomRef가 추가로 할당된 마지막 카드를 가르킬 것이라 생각함. --> useRef 특성이 리렌더링이 일어나더라도 값이 변하지 않는다는(ReadOnly니까..) 특성을 잊은 잘못
    • 이것에 대책으로 카드 리스트 바닥에 heigth 1인 투명한 박스에 bottomRef를 줘서 해당 박스가 시야에 들어오면 fetchNextPage가 일어나도록 변경
import { useCallback, useEffect, useRef, useState } from "react";

type IUseInfiniteScroll = {
  loadMoreFunC: () => void;		// useInfiniteQuery의 fetchNextPage
  shouldMore: boolean;			// useInfiniteQuery의 hasNextPage
};
const useInfiniteScroll = ({
  shouldMore,
  loadMoreFunC,
}: IUseInfiniteScroll) => {
  const bottomRef = useRef<HTMLInputElement>(null);
  const [inView, setInView] = useState(false);

  const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => {
    const target = entries[0];
    setInView(target.isIntersecting);
  }, []);

  useEffect(() => {
    const ob = new IntersectionObserver(handleObserver, { threshold: 1 });
    if (bottomRef.current) ob.observe(bottomRef.current);
    return () => {
      if (bottomRef.current) ob.unobserve(bottomRef.current);
    };
    // obRef.current.
  }, [bottomRef]);

  useEffect(() => {
    if (inView && shouldMore) {
      loadMoreFunC();
    }
  }, [inView, shouldMore]);
  return { inView, bottomRef };
};

export default useInfiniteScroll;