TanStack Query의 useInfiniteQuery
훅을 사용하여 페이지네이션/무한 스크롤 기능을 구현했다.
useInfiniteQuery
기존 데이터에 데이터를 추가적으로 로드해야할 때 유용하게 사용할 수 있는 TanStack Query hook이다.
data.pages
: 각 페이지 데이터를 담고 있는 배열.data.pageParams
: 데이터를 가져오는 데 사용된 페이지 매개변수 배열. (각 페이지 데이터를 가져오는 데 사용된 매개변수들의 기록)fetchNextPage
: 다음 페이지 데이터를 가져오는 함수 (필수).fetchPreviousPage
: 이전 페이지 데이터를 가져오는 함수.initialPageParam
: 초기 페이지 매개변수를 설정 (필수).getNextPageParam
/getPreviousPageParam
: 다음/이전 페이지 데이터를 가져오기 위한 정보 제공.hasNextPage
: 더 로드할 데이터가 있으면 true
.hasPreviousPage
: 이전 데이터가 있으면 true
.isFetchingNextPage
: 추가 데이터 로드 상태 확인.isFetchingPreviousPage
: 이전 데이터 로드 상태 확인.initialData
또는 placeholderData
를 사용할 경우, 반드시 data.pages
와 data.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의 캐시로 관리된다.
- 페이지 이동 시
currentPage를 변경하면, 컴포넌트는 다시 렌더링되지만, 이미 가져온 페이지 데이터는 data.pages에 저장되어 있으므로 새롭게 API를 호출하지 않는다.
isFetchingNextPage
미사용isFetchingNextPage
은 useInfiniteQuery
가 제공하는 상태 중 하나로, 다음 페이지를 로딩 중인지 나타내는 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이다.
지정된 요소가 뷰포트(브라우저 창이나 스크롤 영역)에 얼마나 보이는지를 감지하고, 특정 조건이 충족되었을 때 콜백 함수를 실행한다.
root
: 감시할 부모 요소 (기본값은 null
, 즉 뷰포트).rootMargin
: root
요소의 마진 (예: rootMargin: '0px 0px 100px 0px'
으로 설정하면, 스크롤이 100px 정도 내려왔을 때 요소를 감지).threshold
: 요소가 얼마나 보일 때 콜백을 실행할지 결정하는 값 (0부터 1까지). 1.0은 요소가 완전히 화면에 나타날 때 콜백을 실행.isIntersecting
: IntersectionObserver에서 제공하는 속성으로, 관찰 대상 요소가 뷰포트(Viewport)나 지정된 root 요소와 교차(보이는 상태)에 있는지 여부를 나타낸다.이를 통해 스크롤 이벤트를 계속해서 감지하는 방식이 아니라, 뷰포트와 요소의 교차 여부를 기준으로 데이터를 로딩할 수 있다.
확인을 위해 빨간색 div를 observerRef로 지정했는데, 아주 빠르게 데이터가 fetch 되었다. 이전에 피드백 받았던isFetchingNextPage
을 사용해서 loading을 표시하고자 했는데 아주 빠르게 지나가서 거의 보이지 않고 있다...