TanStack-Query 는 다양한 기능을 제공하며, 그 중 무한스크롤 기능도 제공한다. TanStack-Query에서 제공하는 useInfinityQuery 훅을 사용하면 쉽게 무한스크롤 기능을 구현할 수 있다.
이번 글에서는 TanStack-Query를 이용하여 무한스크롤을 구현하는 과정을 정리해보겠다.
1️⃣ Mock Data
외부 API나 서버를 사용하기 번거로우니 클라이언트 자체에서 Mock Data 를 만들어 사용하자.
// mockData.js
const mockData = Array.from({ length: 100 }, (_, i) => i + 1).map(
(num) => `${num} 데이터입니다.`
);
export function fetchMockData(pageParam) {
const MAX_PAGE_PARAM = 10;
return new Promise((resolve, reject) => {
setTimeout(() => {
if (pageParam <= MAX_PAGE_PARAM) {
resolve(mockData.slice((pageParam - 1) * 10, pageParam * 10));
} else {
reject(new Error('페이지 번호가 최대 페이지 번호를 초과했습니다.'));
}
}, 2000);
});
}
- mockData
- ['1번 데이터입니다', '2번 데이터입니다', ... , '100번 데이터입니다'] 배열
- fetchMockData 함수
- pageParam 매개변수를 받아서 mockData 배열을 slice 메서드로 10개씩 쪼갠다.
- pageParam === 1 => ['1번 데이터입니다', ... '10번 데이터입니다']
- pageParam === 2 => ['11번 데이터입니다', ... '20번 데이터입니다']
- 쪼개진 배열은 Promise의 resolve 값으로 2초 후 리턴된다.
2️⃣ 화면 구현
fetchMockData 함수를 사용하여 mockData를 화면에 렌더링 해보자.
// App.jsx
import './App.css';
import { useEffect, useState } from 'react';
import { fetchMockData } from './mockData';
function App() {
const [items, setItems] = useState(null);
useEffect(() => {
const fetchData = async () => {
const fetchedData = await fetchMockData(1);
setItems(fetchedData);
};
fetchData();
}, []);
return (
<div className="main-container">
{items?.map((item, index) => (
<div key={index}>{item}</div>
))}
</div>
);
}
export default App;
/* App.css */
.main-container {
width: 100%;
background-color: gray;
}
.list-container {
width: 100%;
height: 15vh;
color: azure;
font-size: 24px;
}
3️⃣ useInfinityQuery 훅
현재까지는 fetchMockData 함수의 매개변수에 1을 전달했으므로 1번부터 10번 데이터까지만 표시된다. 만약 매개변수를 2로 변경하면 기존의 1번부터 10번 데이터는 삭제되고, 11번부터 20번 데이터가 화면에 표시될 것이다.
그러나, 무한스크롤의 핵심은 이전 데이터는 그대로 화면에 유지되며 새로운 데이터를 계속 추가하여 화면에 렌더링 하는 것이다. TanStack-Query 에서 제공하는 uesInfiniteQuery 훅을 사용하면 이러한 상황을 쉽게 해결할 수 있다.
우선, useInfiniteQuery 훅에서는 Page라는 개념이 등장한다. 이때, Page를 흔히 우리가 생각하는 책 한 장 한 장의 페이지로 생각하면 혼란스러울 수 있다. 여기서의 Page는 일종의 데이터 덩어리 혹은 단위 즉, Chunk라고 생각하는 것이 맞다.
현재, 무한스크롤을 구현하기 위해서는 useInfinteQuery 함수를 다음과 같이 사용하면 된다.
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['mockData'],
queryFn: ({ pageParam }) => fetchMockData(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) =>
allPages.length <= 10 ? allPages.length + 1 : undefined,
});
useInfitieQuery에 할당한 객체 내의 옵션에 대해 먼저 알아보자.
TanStack-Query에 대한 기초 지식이 있다고 가정하고 queryKey에 대한 내용은 생략하겠다.
- queryFn
- useInfiniteQuery 는 useQuery와 달리 queryFn에 pageParam 을 매개변수로 하는 콜백함수를 넣음
- 그리고, 그 pageParam은 API 함수(fetchMockData)의 매개변수로 할당하는 것이 일반적
- 개발자가 직접 설정하지 않는 이상 pageParam의 초기값은 undefined
- 컴포넌트가 마운트 되면 초기 pageParam 값으로 자동으로 한 번 실행.
- initialPageParam
- queryFn 의 초기 pageParam 값
- 즉, 위의 코드에서 queryFn이 처음 실행될 때 pageParam의 값은 1이 되므로, fetchMockData(1) 함수가 실행됨.
- getNextPageParam
- lastPage, allPages 를 매개변수로 하는 콜백함수를 넣음.
- lastPage는 가장 마지막 queryFn을 통해 받아온 데이터 덩어리
- allPages는 지금까지 받아온 모든 데이터 덩어리의 배열
- 해당 함수의 리턴값은 다음 queryFn이 실행될 때 pageParam의 값으로 할당 됨.
- 현재 mockData의 최대 pageParam은 10이었으므로 삼항연산자를 통해 queryFn의 pageParam이 최대 10까지 들어가도록 설정
이제 구조분해할당을 통해 받아온 값들에 대해 살펴보자.
- data
- pageParam, pages 프로퍼티를 갖는 객체를 반환
- pageParam은 지금까지 실행된 queryFn의 pageParam 값을 순서대로 기록한 배열 (queryFn의 pageParam과 다름)
- pages는 지금까지 받아온 모든 데이터 즉, allPages랑 같음.
- fetchNextPage
- getNexPageParam 함수를 실행시켜 변화된 pageParam 값으로 queryFn을 실행시키는 함수
- pageParam이 undefined면 queryFn 실행 x
- hasNextPage
- 다음 받아올 데이터가 존재하는지의 여부를 나타내는 boolean 값
- isFetchingNextPage
- 데이터를 받아오고 있는지 여부를 나타내는 boolean 값
백문이불어일견 테스트 해보자.
// main.jsx
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
// App.jsx
import './App.css';
import { fetchMockData } from './mockData';
import { useInfiniteQuery } from '@tanstack/react-query';
function App() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['mockData'],
queryFn: ({ pageParam }) => fetchMockData(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) =>
allPages.length < 10 ? allPages.length + 1 : undefined,
});
console.log(data)
// data.pages가 이차원 배열이므로 flat 메서드 이용
const allItems = data?.pages?.flat();
return (
<div className="main-container">
{allItems?.map((item, index) => (
<div className="list-container" key={index}>
{item}
</div>
))}
</div>
);
}
export default App;
이제 fetchNextPage 함수와 hasNextPage, isFetchingNextPage 값을 사용하여 UI 표현 및 데이터를 추가적으로 받아오자.
// App.jsx
function App() {
// 생략
return (
<div className="main-container">
{allItems?.map((item, index) => (
<div className="list-container" key={index}>
{item}
</div>
))}
{data && hasNextPage && !isFetchingNextPage && (
<button onClick={fetchNextPage}>데이터 추가</button>
)}
</div>
);
}
export default App;
button 태그를 추가하고 onClick 이벤트 핸들러에 fetchNextPage 함수를 할당했다.
데이터 추가 버튼은 초기 데이터를 받아온 후, 다음 데이터를 가져올 수 있는 상태이면서 데이터 로딩 중이 아닐 때만 표시되도록 하여, UI의 어색함을 해소했다.
4️⃣ 무한스크롤 구현
이제 지금처럼 버튼을 눌렀을 때 데이터를 추가적으로 받아오는 것이 아닌, 본래의 무한스크롤처럼 스크롤을 내렸을 때 데이터를 추가적으로 받아오게끔 해보자.
이를 위해 IntersectionObserver를 이용한다. IntersectionObserver는 내가 원하는 요소가 현재 뷰포트에서 보이는지 안 보이는지 추적할 수 있는 객체이다. 리액트에서는 react-intersection-observer 라이브러리를 통해 보다 편하게 사용할 수 있다.
먼저, react-intersection-observer 라이브러리를 설치한다.
npm i react-intersection-observer
사용법은 매우 간단하다.
아래와 같이 useInView 훅을 호출한다.
const [ref, inView] = useInView();
useRef 훅을 사용해보았다면 ref의 의미를 바로 파악할 수 있을 것이다.
ref는 내가 추적할 태그(요소)의 ref 속성으로 넣어주면 된다.
inView는 추적할 요소가 화면에 보이면 true, 보이지 않으면 false가 되는 boolean 값이다.
따라서, 이제 기존의 button 태그 대신 보이지 않는 div 태그를 만들어 useInView 훅을 이용해보자.
// App.jsx
import { useEffect } from 'react';
import './App.css';
import { fetchMockData } from './mockData';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
function App() {
// 생략
const [ref, inView] = useInView();
console.log(inView)
return (
<div className="main-container">
{allItems?.map((item, index) => (
<div className="list-container" key={index}>
{item}
</div>
))}
{data && hasNextPage && !isFetchingNextPage && <div ref={ref}></div>}
</div>
);
}
export default App;
최하단의 숨겨진 div 태그가 뷰포트에 나타나는 순간에 inView가 true가 되는 것을 확인할 수 있다.
이제 inView가 true가 될 때, fetchNextPage 함수를 실행하면 무한스크롤이 완성된다.
useEffect 훅을 이용하자.
// App.jsx
import { useEffect } from 'react';
import './App.css';
import { fetchMockData } from './mockData';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
function App() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['mockData'],
queryFn: ({ pageParam }) => fetchMockData(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) =>
allPages.length < 10 ? allPages.length + 1 : undefined,
});
const [ref, inView] = useInView();
useEffect(() => {
if (inView) fetchNextPage();
}, [inView, fetchNextPage]);
const allItems = data?.pages?.flat();
return (
<div className="main-container">
{allItems?.map((item, index) => (
<div className="list-container" key={index}>
{item}
</div>
))}
{data && hasNextPage && !isFetchingNextPage && <div ref={ref}></div>}
</div>
);
}
export default App;
참고로, useEffect를 사용하지 않고 useInView 훅의 매개변수를 할당해 다음과 같이 구현할 수도 있다.
// App.jsx
import './App.css';
import { fetchMockData } from './mockData';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
function App() {
// 생략
const [ref] = useInView({
triggerOnce: true,
onChange: (inView) => {
if (inView) fetchNextPage();
},
});
return (
// 생략
}
export default App;
5️⃣ 스켈레톤 UI
추가적으로 스켈레톤 UI도 구현해서 적용해보자. 현재는 fetch 요청이 발생하면 2초 후에 데이터를 받아온다. 2초의 시간 동안 화면에 어떠한 변화도 없으니 UX가 매우 떨어지기 마련이다.
다음과 같이 스켈레톤 UI를 만들었다.
import './Skeleton.css';
const Skeleton = () => {
return (
<>
{[...Array(10)].map((_, index) => (
<div className="skeleton-container" key={index}></div>
))}
</>
);
};
export default Skeleton;
/* Skeleton.css */
.skeleton-container{
color: gray;
height: 15vh;
}
이제 isFetchinNextPage 가 true일 때, 스켈레돈 UI를 화면에 렌더링 하면 된다.
// App.jsx
import './App.css';
import { fetchMockData } from './mockData';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import Skeleton from './skeleton';
function App() {
// 생략
return (
<div className="main-container">
{allItems?.map((item, index) => (
<div className="list-container" key={index}>
{item}
</div>
))}
{isFetchingNextPage && <Skeleton/>}
{data && hasNextPage && !isFetchingNextPage && <div ref={ref}></div>}
</div>
);
}
export default App;
쿼리가 실행되고 데이터를 불러올 오는 사이에 스켈레톤 UI가 렌더링 되어 UX가 전보다 크게 향상된 것을 체감할 수 있다.
'React' 카테고리의 다른 글
[React] TanStack-Query 의 useMutation 훅과 낙관적(optimistic) 업데이트 구현하기 (0) | 2025.02.19 |
---|---|
[React] 비동기 데이터 상태 관리와 TanStack-Query 라이브러리 (1) | 2024.12.19 |
[React] 바닐라JS로 가상돔(VirtualDOM) 만들기 (0) | 2024.10.02 |