내일배움캠프 React_7기 TIL - 37. useInfiniteQuery를 사용한 페이지네이션/무한스크롤

·2024년 12월 6일
0

TanStack Query의 useInfiniteQuery 훅을 사용하여 페이지네이션/무한 스크롤 기능을 구현했다.

useInfiniteQuery

기존 데이터에 데이터를 추가적으로 로드해야할 때 유용하게 사용할 수 있는 TanStack Query hook이다.

주요 특징

  1. 데이터 구조
    • data.pages: 각 페이지 데이터를 담고 있는 배열.
    • data.pageParams: 데이터를 가져오는 데 사용된 페이지 매개변수 배열. (각 페이지 데이터를 가져오는 데 사용된 매개변수들의 기록)
  2. 필수 함수
    • fetchNextPage: 다음 페이지 데이터를 가져오는 함수 (필수).
    • fetchPreviousPage: 이전 페이지 데이터를 가져오는 함수.
  3. 필수 옵션
    • initialPageParam: 초기 페이지 매개변수를 설정 (필수).
    • getNextPageParam/getPreviousPageParam: 다음/이전 페이지 데이터를 가져오기 위한 정보 제공.
  4. 추가 상태값
    • hasNextPage: 더 로드할 데이터가 있으면 true.
    • hasPreviousPage: 이전 데이터가 있으면 true.
    • isFetchingNextPage: 추가 데이터 로드 상태 확인.
    • isFetchingPreviousPage: 이전 데이터 로드 상태 확인.
  5. 중요 사항
    • initialData 또는 placeholderData를 사용할 경우, 반드시 data.pagesdata.pageParams 구조를 따라야 한다.

이전/다음 페이지네이션 구현

구현 코드

import { useState } from "react";
import { fetchPokemons } from "../api/api.js";
import PokemonCard from "../components/PokemonCard.jsx";
import { useInfiniteQuery } from "@tanstack/react-query";

const PaginationPage = () => {
  const [currentPage, setCurrentPage] = useState(1);

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetching,
    isError,
  } = useInfiniteQuery({
    queryKey: ["pokemons"],
    queryFn: ({ pageParam = 1 }) => fetchPokemons({ pageParam }),
    getNextPageParam: (lastPage) => lastPage.nextPage,
  });

  if (isFetching) return <div>Loading...</div>;
  if (isError) return <div>Error loading data</div>;

  const currentData = data?.pages[currentPage - 1]?.data || [];

  return (
    <div>
      <h2 style={{ textAlign: "center" }}>Pagination</h2>
      <div
        style={{
          display: "flex",
          flexWrap: "wrap",
          gap: "10px",
          justifyContent: "center",
        }}
      >
        {currentData.length > 0 ? (
          currentData.map((pokemon) => (
            <PokemonCard key={pokemon.id} pokemon={pokemon} />
          ))
        ) : (
          <div>데이터 없음</div>
        )}
      </div>

      <div>
        <button onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}>
          Previous
        </button>
        <span style={{ margin: "0 10px" }}>Page {currentPage}</span>

        <button
          onClick={() => {
            if (currentPage === data.pages.length && hasNextPage) {
              fetchNextPage();
              setCurrentPage((prev) => prev + 1);
            }
          }}
        >
          Next
        </button>
      </div>
    </div>
  );
};

export default PaginationPage;

currentPage 로 현재 페이지의 상태를 관리하고, useInfiniteQuery를 사용하여 포켓몬 데이터를 요청하고 관리하고 있다.

구현 중 어려웠던 점

1. data의 구조
data에 .map을 사용하였더니 오류가 발생했다. 이유는 useInfiniteQuery에서 반환되는 data는 기본적으로 페이지 단위의 배열 구조를 가진 객체였기 때문이다.

{
  pages: [
    { data: [/* 첫 번째 페이지 데이터 */] },
    { data: [/* 두 번째 페이지 데이터 */] },
    { data: [/* 세 번째 페이지 데이터 */] },
    // ... 추가 페이지 데이터
  ],
  pageParams: [/* 각 페이지의 요청 매개변수 */]
}

올바르게 데이터를 렌더링하려면 각 페이지의 데이터를 평평하게(flatten) 만들어야 했다.
수정 전

 {data.map((pokemon) => (
          <PokemonCard key={pokemon.id} pokemon={pokemon} />
        ))}

수정 후

{data?.pages.flatMap((page) =>
          page.data.map((pokemon) => (
            <PokemonCard key={pokemon.id} pokemon={pokemon} />
          ))
        )}

flatMap이란?
flatMap은 JavaScript 배열 메서드로, 각 배열 요소에 대해 매핑(mapping)을 수행한 후, 결과를 하나의 평평한(flat) 배열로 결합하는 역할

flatMap 적용 후 화면에 포켓몬 데이터가 잘 표시되었다.

2. 해당 페이지에 해당하는 데이터만 표시하기
처음에는 useInfiniteQuery에서 주는 data를 그대로 사용했는데, 그 결과 해당 페이지의 데이터만 나오는 것이 아닌 이전 페이지의 데이터 + 현재 페이지의 데이터까지 모두 화면에 나타나게 되었다.
그래서 특정 페이지에 해당하는 데이터만 표시하기 위해 data.pages에서 현재 페이지에 해당하는 데이터만 추출하여 사용하도록 수정하였다.
이를 위해 data.pages 를 활용하였다.

const currentData = data?.pages[currentPage - 1]?.data || [];

data.pages에 접근하여 캐싱된 페이지별 데이터를 담는다.
currentPage - 1 인덱스를 통해 해당 페이지의 데이터만 추출하여 변수에 담고, 화면에 표시하도록 한다. 데이터가 없을 경우 undefined가 반환되지 않도록 빈 배열([])을 기본값으로 설정하였다.

3. fetchPreviousPage를 적용하지 않아도 이전 페이지가 나오는 이유?
바로 TanStack Query의 캐싱 덕분이다. TanStack Query는 쿼리 키(queryKey) 기준으로 데이터 요청의 결과를 캐싱한다. 그리고, 동일한 쿼리 키에 대해 데이터를 요청하면, API를 호출하지 않고 캐시된 데이터를 반환해준다.

❇️ 동작 매커니즘
1. useInfiniteQuery 실행 시
데이터를 가져올 때, 각 페이지 데이터는 data.pages 배열에 저장된다.
이 데이터는 쿼리 키 "pokemons"에 연결되어 React Query의 캐시로 관리된다.


  1. 페이지 이동 시
    currentPage를 변경하면, 컴포넌트는 다시 렌더링되지만, 이미 가져온 페이지 데이터는 data.pages에 저장되어 있으므로 새롭게 API를 호출하지 않는다.

피드백

  1. isFetchingNextPage 미사용
    isFetchingNextPageuseInfiniteQuery가 제공하는 상태 중 하나로, 다음 페이지를 로딩 중인지 나타내는 boolean값인데, 이것을 활용하여 로딩 인디케이터를 표시하거나, 불필요한 추가 요청을 방지할 수 있다.

무한 스크롤 구현

구현 코드

import { useEffect, useRef } from "react";
import { fetchPokemons } from "../api/api.js";
import PokemonCard from "../components/PokemonCard.jsx";
import { useInfiniteQuery } from "@tanstack/react-query";

const InfiniteScrollPage = () => {
  const { data, fetchNextPage, hasNextPage, isPending, isFetchingNextPage } =
    useInfiniteQuery({
      queryKey: ["pokemons"],
      queryFn: ({ pageParam = 1 }) => fetchPokemons({ pageParam }),
      getNextPageParam: (lastPage) => lastPage.nextPage,
    });

  const observerRef = useRef();

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      },
      { threshold: 1 }
    );
    if (observerRef.current) {
      observer.observe(observerRef.current); // 관찰 시작
    }

    if (isPending) {
      console.log("로딩중");
    }

    return () => {
      if (observerRef.current) observer.disconnect(); // 관찰 종료
    };
  }, [fetchNextPage, hasNextPage]);

  return (
    <div>
      <h2 style={{ textAlign: "center" }}>Infinite Scroll</h2>
      <div
        style={{
          display: "flex",
          flexWrap: "wrap",
          gap: "10px",
          justifyContent: "center",
        }}
      >
        {data?.pages.flatMap((page) =>
          page.data.map((pokemon) => (
            <PokemonCard key={pokemon.id} pokemon={pokemon} />
          ))
        )}
      </div>
      {isFetchingNextPage ? (
        <div style={{ textAlign: "center", marginTop: "20px" }}>Loading...</div>
      ) : (
        <></>
      )}

      <div ref={observerRef} style={{ height: "20px", background: "red" }} />
    </div>
  );
};

export default InfiniteScrollPage;

무한 스크롤 구현의 포인트는 observer인 것 같다.
IntersectionObserver란, Intersection Observer API의 인터페이스로 DOM 요소가 다른 요소와 교차하는지 (즉, 화면에 보이는지) 감지하는 API이다.
지정된 요소가 뷰포트(브라우저 창이나 스크롤 영역)에 얼마나 보이는지를 감지하고, 특정 조건이 충족되었을 때 콜백 함수를 실행한다.

IntersectionObserver의 주요 속성

  • root: 감시할 부모 요소 (기본값은 null, 즉 뷰포트).
  • rootMargin: root 요소의 마진 (예: rootMargin: '0px 0px 100px 0px'으로 설정하면, 스크롤이 100px 정도 내려왔을 때 요소를 감지).
  • threshold: 요소가 얼마나 보일 때 콜백을 실행할지 결정하는 값 (0부터 1까지). 1.0은 요소가 완전히 화면에 나타날 때 콜백을 실행.
  • isIntersecting: IntersectionObserver에서 제공하는 속성으로, 관찰 대상 요소가 뷰포트(Viewport)나 지정된 root 요소와 교차(보이는 상태)에 있는지 여부를 나타낸다.

이를 통해 스크롤 이벤트를 계속해서 감지하는 방식이 아니라, 뷰포트와 요소의 교차 여부를 기준으로 데이터를 로딩할 수 있다.

확인을 위해 빨간색 div를 observerRef로 지정했는데, 아주 빠르게 데이터가 fetch 되었다. 이전에 피드백 받았던isFetchingNextPage을 사용해서 loading을 표시하고자 했는데 아주 빠르게 지나가서 거의 보이지 않고 있다...

profile
내배캠 React_7기 이수중

0개의 댓글

관련 채용 정보