FrontEnd/React.js

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

위그든씨 2024. 9. 3. 14:59

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

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

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;