React Query 강좌 7편. 페이지네이션 구현하기와 useInfiniteQuery로 무한스크롤 구현하기
안녕하세요?
일곱 번째 React Query 강좌 시리즈입니다
전체 시리즈 링크는 아래와 같습니다.
-
React Query 강좌 2편. 캐시로 움직이는 useQuery 작동 원리(cachetime,staletime,refetch,poll)
-
React Query 강좌 3편. 클릭시 fetch하는 방법과 커스텀 콜백함수 작성, useQuery에서 데이터 변환, 커스텀 훅 만들기
-
React Query 강좌 4편. id로 특정 항목만 가져오는 쿼리 방법(query by id)과 병렬 쿼리(parallel queries) 방법
-
React Query 강좌 5편. 동적 병렬 쿼리(dynamic parallel queries)와 필요충분 쿼리 만들기(dependent query)
-
React Query 강좌 6편. useQueryClient와 initialData를 이용해서 캐시된 데이터 활용하여 상세 페이지에서 보여주기
-
React Query 강좌 7편. 페이지네이션 구현하기와 useInfiniteQuery로 무한스크롤 구현하기
** 목 차 **
1. 페이지네이션(Pagination) 구현하기
우리가 API를 통해 데이터를 가져오면 보통 10개씩 보여줍니다.
10개씩 보여주는 거는 디폴트 값이고, 아래와 같이 사용자가 직접 몇 개씩 가져올지 정할 수 있습니다.
위 그림은 pocketbase가 제공하는 페이지네이션 관련 정보인데요.
page와 perPage 등 관련 정보가 제공되고 있네요.
보통 좋은 API면 totalItems와 totalPages 관련 정보도 꼭 제공해 줘야 합니다.
그래야 게시판 같은 걸 만들 때 처음과 끝을 계산해서 페이지 정보를 보여줄 수 있기 때문입니다.
오늘은 React Query로 페이지네이션을 구현해 보겠습니다.
PaginatedQuery.jsx 컴포넌트를 새로 만들겠습니다.
그래서 App.jsx 파일에 아래와 같이 라우팅을 꼭 추가해야 합니다.
import { PaginatedQuery } from "./PaginatedQuery";
...
...
<Route path="/paginated-query" element={<PaginatedQuery />} />
이제 PaginatedQuery 컴포넌트를 만들겠습니다.
사실 지금까지 배운 useQuery 관련 정보를 응용하는 건데요.
import { useState } from 'react'
import { useQuery } from 'react-query'
import axios from 'axios'
const fetchProducts = pageParam => {
return axios.get(
`https://mypocketbase.fly.dev/api/collections/products/records/?perPage=4&page=${pageParam}`,
)
}
export const PaginatedQuery = () => {
const [pageNumber, setPageNumber] = useState(1)
const { data, isLoading, isFetching } = useQuery(
['get-paginated', pageNumber],
() => fetchProducts(pageNumber),
)
if (isLoading) return <div>Loading...</div>
return (
<>
<div className='text-4xl'>ReactQuery</div>
<h2>current Page number : {pageNumber}</h2>
<ul className='list-disc p-4'>
{data &&
data.data?.items?.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
<div className='space-x-4'>
<button
onClick={() => setPageNumber(page => page - 1)}
disabled={pageNumber === 1}
>
Prev
</button>
<button
onClick={() => setPageNumber(page => page + 1)}
disabled={pageNumber === 3}
>
Next
</button>
</div>
<div>{isFetching && 'Fetching...'}</div>
</>
)
}
위와 같이 'get-paginated'라는 쿼리가 pageNumber에 의해 구분되어서 잘 실행되고 있습니다.
실제 작동 순서는 다음과 같습니다.
Next 버튼을 누르면 쿼리가 작동하고 실제 데이터 fetching이 일어나고 그러 화면에 뿌려줍니다.
그런데, 우리가 React Query를 쓰는 이유가 있죠. 캐시 된 데이터를 활용하는 건데요.
여기서 useQuery가 제공해 주는 강력한 추가 기능이 있습니다.
Next 버튼이나 Prev 버튼을 눌렀을 때 캐시 된 데이터를 화면에 먼저 보여주는 겁니다.
그러고 나서 fetching을 해서 기존 데이터와 같으면 UI를 바꾸지 않고, 만약 틀리다면 그제야 UI를 바꾸는 겁니다.
캐시 된 데이터를 fetching이 일어나기 전에 화면에 먼저 보여주는 게 왜 중요한지 알려드리겠습니다.
여러분이 방대한 양의 게시판을 작성한다고 할 때 서버에서 데이터를 fetch하는 속도가 느릴 겁니다.
fetch한 자료를 UI에 보여줘야 하는데, 서버 사이드 렌더링이 아니라 React를 이용해서 클라이언트 렌더링을 하게 된다면,
fetching 하고 있을 때 UI가 사라지게 됩니다.
그러면 그 UI 부분이 없어져서 전체적인 화면의 Layout이 깨지게 되는데요.
그래서 React에서는 Skeleton UI 같은 걸 제공해 주는 이유가 여기에 있습니다.
아주 복잡한 CSS 구조로 되어 있으면 데이터 로딩 중에 Layout이 틀어져 보일 수 있기 때문이죠.
그래서 ReactQuery 에서는 이 부분을 방지하고자, 캐시 된 데이터를 먼저 보여주고 나중에 fetching이 끝난 다음 다시 화면에 데이터를 업데이트해 주는 방식을 제공합니다.
이 기능은 옵션을 주면 되는데요.
const { data, isLoading, isFetching } = useQuery(
['get-paginated', pageNumber],
() => fetchProducts(pageNumber),
{
keepPreviousData: true,
},
)
위와 같이 'keepPreviousData' 항목을 true라고 해주면 됩니다.
2. useInfiniteQuery 사용법
모바일에서 구글 검색하면 페이지 밑으로 스크롤 하면 다음 페이지 내용이 자동으로 로딩되는 걸 본 적이 있는데요.
바로 무한 스크롤을 사용했기 때문입니다.
무한 스크롤을 구현하기 위해서는 useInfiniteQuery 함수를 사용해야 하는데요.
무한 스크롤 구현 전에 먼저, useInfiniteQuery 함수 사용법을 간단하게 익혀 보겠습니다.
PaginatedQuery 컴포넌트를 수정해서 사용하겠습니다.
import { Fragment } from 'react'
import { useInfiniteQuery } from 'react-query'
import axios from 'axios'
const fetchProducts = (page) => {
return axios.get(
`https://mypocketbase.fly.dev/api/collections/products/records/?perPage=4&page=${page}`,
)
}
export const PaginatedQuery = () => {
const {ƒ
data,
isLoading,
isFetching,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useInfiniteQuery(
['get-paginated'],
({ pageParam = 1 }) => fetchProducts(pageParam),
{
getNextPageParam: (_lastPage, pages) => {
if (pages.length < 3) {
return pages.length + 1
} else return undefined
},
},
)
if (isLoading) return <div>Loading...</div>
return (
<>
<div className='text-4xl'>ReactQuery</div>
{data &&
data.pages?.map((group, i) => (
<Fragment key={i}>
{group &&
group?.data.items.map(p => <div key={p.id}>{p.name}</div>)}
</Fragment>
))}
<div className='space-x-4'>
<button
className='border'
onClick={fetchNextPage}
disabled={!hasNextPage}
>
Load More
</button>
</div>
<div>{isFetching && !isFetchingNextPage ? 'Fetching...' : null}</div>
</>
)
}
예전 useQuery 함수의 사용법과 사뭇 많이 다릅니다.
먼저, fetchProducts 함수를 볼까요?
page라는 함수 인자를 받고 있습니다.
그러면 fetchProducts 함수 호출 부분을 볼까요?
useInfiniteQuery에 넘겨진 함수 부분인데요.
({ pageParam = 1 }) => fetchProducts(pageParam)
위 코드처럼 pageParam 값을 디폴트 값으로 1로 지정해서 넘겨줘야 합니다.
그리고 useInfiniteQuery 함수가 제공하는 pageParam 값을 함수 인자로 넘겨주면 됩니다.
pageParam은 useInfiniteQuery 가 fetchProducts 함수를 제어할 때 내부적으로 제공하는 변수입니다.
이걸로 몇 페이지, 몇 페이지를 직접 찾는 거죠.
그리고 useInfiniteQuery 함수의 사용법입니다.
주의 사항useInfiniteQuery는 queryKey를 꼭 배열로 제공해 줘야 합니다.
fetch 함수는 위와 같이 인자 없이 그냥 전달 했구요.
마지막으로 옵션에 넣어줄 항목이 바로 'getNextPageParam' 콜백 함수입니다.
이 콜백 함수는 말 그대로 다음 페이지의 pageParam 값을 제공하는 겁니다.
우리 예제에서는 총 3 페이지기 때문에 위와 같이 작성 했구요.
꼭 undefined를 리턴하는 코드도 작성해야 합니다.
주의 사항UI 부분에서 사용하는 데이터가 data.data가 아니라 data.pages입니다.
즉, useInfiniteQuery는 페이지 정보를 가져다주고, 실제 그 pages에는 해당 페이지가 있고, group이란 항목이 있습니다.
그래서 위와 같이 map 메서드를 두 번 써야 됩니다.
{
data &&
data.pages?.map((group, i) => (
<Fragment key={i}>
{group && group?.data.items.map(p => <div key={p.id}>{p.name}</div>)}
</Fragment>
))
}
조금은 복잡할 수 있으니까요?
테스트 여러 번 해보시면 금방 이해될 겁니다.
그러면 다음 페이지는 어떻게 수행할까요?
바로 fetchNextPage 콜백 함수를 사용하면 됩니다.
<button className='border' onClick={fetchNextPage} disabled={!hasNextPage}>
Load More
</button>
우리가 위와 같이 버튼을 작성했고 onClick 핸들러로 fetchNextPage를 지정해 줬습니다.
그래서 이 버튼만 누르면 useInfiniteQuery의 다음 페이지 찾기가 작동되는 겁니다.
또, hasNextPage 값으로 disabled 값도 조정할 수 있고요.
위 그림과 같이 Load More 버튼을 누르면 계속 데이터가 나타날 겁니다.
3. 무한 스크롤 구현하기
useInfiniteQuery는 말 그래도 무한 스크롤을 구현하게 해 주는데요.
브라우저에서 기본적으로 제공하는 DOM Element의 scrollHeight, scrollTop, clientHeight 등을 통해 무한 스크롤을 구현해 보겠습니다.
useEffect(() => {
let fetching = false;
const handleScroll = async (e) => {
const { scrollHeight, scrollTop, clientHeight } =
e.target.scrollingElement;
if (!fetching && scrollHeight - scrollTop <= clientHeight * 1.2) {
fetching = true;
if (hasNextPage) await fetchNextPage();
fetching = false;
}
};
document.addEventListener("scroll", handleScroll);
return () => {
document.removeEventListener("scroll", handleScroll);
};
}, [fetchNextPage, hasNextPage]);
hasNextPage 값과 fetchNextPage 함수를 이용해서 브라우저에서 스크롤이 발생했을 때 fetchNextPage() 함수를 수행하라고 하는 코드입니다.
위 useEffect 코드는 꼭 useInfiniteQuery 코드 다음에 작성해야 합니다.
그래야지 useEffect에서 fetchNextPage, hasNextPage 변수를 참조할 수 있기 때문입니다.
아마도, useInfiniteQuery 함수가 useEffect 보다 먼저 실행되는 걸로 보입니다.
import { Fragment, useEffect } from "react";
import { useInfiniteQuery } from "react-query";
import axios from "axios";
const fetchProducts = (page) => {
return axios.get(
`https://mypocketbase.fly.dev/api/collections/products/records/?perPage=4&page=${page}`
);
};
export const PaginatedQuery = () => {
const { data, isLoading, hasNextPage, fetchNextPage } = useInfiniteQuery(
["get-paginated"],
({ pageParam = 1 }) => fetchProducts(pageParam),
{
getNextPageParam: (_lastPage, pages) => {
if (pages.length < 3) {
return pages.length + 1;
} else return undefined;
},
}
);
useEffect(() => {
let fetching = false;
const handleScroll = async (e) => {
const { scrollHeight, scrollTop, clientHeight } =
e.target.scrollingElement;
if (!fetching && scrollHeight - scrollTop <= clientHeight * 1.2) {
fetching = true;
if (hasNextPage) await fetchNextPage();
fetching = false;
}
};
document.addEventListener("scroll", handleScroll);
return () => {
document.removeEventListener("scroll", handleScroll);
};
}, [fetchNextPage, hasNextPage]);
if (isLoading) return <div>Loading...</div>;
return (
<>
<div className="text-4xl">ReactQuery</div>
{data &&
data.pages?.map((group, i) => (
<Fragment key={i}>
{group &&
group?.data.items.map((p) => <div key={p.id}>{p.name}</div>)}
</Fragment>
))}
</>
);
};
실행 결과를 볼까요?
위 두 개의 그림처럼 스크롤 하면 useInfiniteQuery가 정상적으로 작동합니다.
4. Intersection Observer 사용하여 무한 스크롤 구현하기
Intersection Observer API 라는게 있는데요.
최신 브라우저에는 모두 있는 겁니다.
이걸 이용해 볼 건데요.
PaginatedQuery2를 만들어 보겠습니다.
라우팅에도 추가해야겠죠.
import { Fragment, useEffect, useRef, useCallback } from "react";
import { useInfiniteQuery } from "react-query";
import axios from "axios";
const fetchProducts = (page) => {
return axios.get(
`https://mypocketbase.fly.dev/api/collections/products/records/?perPage=4&page=${page}`
);
};
export const PaginatedQuery2 = () => {
const observerElem = useRef(null);
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } =
useInfiniteQuery(
["get-paginated"],
({ pageParam = 1 }) => fetchProducts(pageParam),
{
getNextPageParam: (_lastPage, pages) => {
if (pages.length < 3) {
return pages.length + 1;
} else return undefined;
},
}
);
const handleObserver = useCallback(
(entries) => {
const [target] = entries;
if (target.isIntersecting && hasNextPage) {
fetchNextPage();
}
},
[fetchNextPage, hasNextPage]
);
useEffect(() => {
const element = observerElem.current;
let options = {
root: null,
rootMargin: "0px",
threshold: 1,
};
const observer = new IntersectionObserver(handleObserver, options);
if (element) observer.observe(element);
return () => {
if (element) observer.unobserve(element);
};
}, [fetchNextPage, hasNextPage, handleObserver]);
if (isLoading) return <div>Loading...</div>;
return (
<>
<div className="text-4xl">ReactQuery</div>
{data &&
data.pages?.map((group, i) => (
<Fragment key={i}>
{group &&
group?.data.items.map((p) => <div key={p.id}>{p.name}</div>)}
</Fragment>
))}
<div className="loader" ref={observerElem}>
{isFetchingNextPage && hasNextPage ? "Loading..." : "No search left"}
</div>
</>
);
};
여기서 가장 중요한 useEffect 함수를 살펴볼까요?
useEffect(() => {
const element = observerElem.current;
let options = {
root: null,
rootMargin: "0px",
threshold: 1,
};
const observer = new IntersectionObserver(handleObserver, options);
if (element) observer.observe(element);
return () => {
if (element) observer.unobserve(element);
};
}, [fetchNextPage, hasNextPage, handleObserver]);
IntersectionObserver 객체를 observer 변수에 넣었고, 그걸 이용해서 element DOM 노드를 감사하는 겁니다.
여기서 element는 observerElem인데요.
우리가 마지막에 넣었던 div 태그입니다.
아래 코드처럼요.
<div className="loader" ref={observerElem}>
{isFetchingNextPage && hasNextPage ? "Loading..." : "No search left"}
</div>
useRef로 넘겨주기 때문에 꼭 observerElem.current로 DOM 노드를 넘겨줘야 합니다.
안 그러면 Failed to execute 'observe' on 'IntersectionObserver': parameter 1 is not of type 'Element'. 에러가 발생됩니다
그리고 가장 중요한 부분이 바로 observer.observe 하기 전에 꼭 element가 있는지 없는지 if 문으로 체크해야 합니다.
unobserve에서도 꼭 element가 있는지 없는지 if 문으로 체크해야 합니다.
이 if 문이 없으면 Failed to execute 'observe' on 'IntersectionObserver': parameter 1 is not of type 'Element'. 에러가 발생됩니다.
useEffect가 React 코드의 useRef 보다 먼저 실행되기 때문에 그렇습니다.
실행해 보시면 아까랑 같이 잘 작동할 겁니다.
그럼.