최근에 React-query를 학습하고 이제 기존 프로젝트에 도입해서 사용하려 하는데 프로젝트의 메인 페이지에서는 무한스크롤 기능이 작동되고 있었다. 마침 React-query에 useInfiniteQuery라는 파라미터 값만 변경하여 새로운 데이터를 무한적으로 호출할 때 사용되는 hook이 있어 기존에 있던 Intersection Observer기능과 접목해서 사용해 보았다.
Data Fetching이 일어날 때 마다 기존 리스트 데이터에 Fetched Data 를 추가하고자 할 때 유용하게 사용할 수 있는 React-query hook으로, 파라미터 값만 변경하여 새로운 데이터를 무한적으로 호출할 때 사용된다.(동일한 useQuery를 무한정 호출). 더보기 UI 또는 무한스크롤 UI 에 사용하기에 적합하다.
const {
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
...result
} = useInfiniteQuery({
queryKey,
queryFn: ({ pageParam = 1 }) => fetchPage(pageParam),
...options,
getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
})
기본적인 형태는 useQuery와 비슷하다. 데이터를 캐싱하기 위한 고유한 식별자로 queryKey를 설정하고,
queryFn은 data를 fetch하는 함수를 작성해주는데, 이때 queryFn의 매개변수로는 QueryFunctionContext가 전달이 된다. QueryFunctionContext는 React Query의 내부 컨텍스트(Context) 중 하나이다. 이 컨텍스트는 쿼리 함수에서 사용할 수 있는 추가적인 정보와 기능을 제공하고, queryKey, pageParam, meta, signal 속성이 있는 객체를 담고있다. 이러한 속성들은 QueryFunctionContext를 통해 쿼리 함수가 실행될 때 전달되며, 데이터를 가져오고 처리하는 데 사용된다. 이를 통해 React Query는 동적이고 유연한 방식으로 데이터를 관리하고 캐싱할 수 있다.
이 중에 pageParam을 queryFn에 넣어주는데, 기본 설정으로 pageParam을 1로 지정해준다.
queryKey: 이는 쿼리를 식별하는 데 사용되는 키이다. 쿼리 함수가 실행될 때 이 키를 기반으로 데이터를 가져온다. 일반적으로 배열 형태로 전달되며, 쿼리 함수에 필요한 모든 매개변수와 관련된 정보가 포함될 수 있다.
pageParam: 이는 페이지네이션된 데이터를 처리할 때 다음 페이지를 가져오는 데 사용되는 매개변수이다. 현재 페이지의 데이터를 가져오기 위해 쿼리 함수에 전달된다. 일반적으로 숫자 형태이지만, 필요에 따라 다른 형식일 수도 있다.
meta: 이는 쿼리 함수의 실행에 관련된 기타 메타데이터를 포함하는 객체이다. 이 메타데이터는 쿼리 함수의 실행과 관련된 추가적인 정보를 제공할 수 있다. 예를 들어 캐싱, 요청 중단 등에 사용될 수 있다.
signal: 이는 쿼리 함수의 실행을 제어하는데 사용되는 신호 객체이다. 주로 요청의 취소나 중단을 관리하는 데에 사용된다. 일반적으로 AbortSignal 인터페이스의 인스턴스이며, 네트워크 요청을 취소하거나 중단하기 위해 사용될 수 있다.
파라미터가 변경되면 data.pages에 저장된 배열의 끝에 새로운 데이터가 계속 삽입이 된다.
lastPage: 마지막으로 가져온 페이지의 데이터. 이는 페이지의 실제 데이터가 아니라 마지막 페이지의 데이터 객체를 나타낸다. (캐시 된 데이터 중 가장 최신의 데이터)
allPages: 이전에 가져온 모든 페이지의 데이터가 포함된 배열. 이 배열은 마지막 페이지를 포함하여 현재까지 가져온 모든 페이지를 담고 있다.
getNextPageParam 함수는 이전 페이지의 데이터를 기반으로 다음 페이지의 매개변수를 결정하고 반환한다. 일반적으로 이 함수는 마지막 페이지에서 다음 페이지로 이동하는 데 필요한 정보를 제공하는데 사용된다. 이 함수는 보통 다음 페이지의 매개변수를 반환하거나, 더 이상 가져올 페이지가 없는 경우 false를 반환한다.
import { useNavigate } from 'react-router-dom';
import styled from "styled-components";
import useInfiniteScroll from '../hooks/use-infinitescroll';
import { apis } from '../shared/api';
import { Product } from './types/product';
import LazyLoadingImage from './LazyLoading';
import PlaceholderImg from '../assets/images/placeholderImg.svg';
import { useInfiniteQuery } from '@tanstack/react-query';
function MainGrid() {
const navigate = useNavigate()
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery<Product[], Error>({
queryKey: ['products'],
queryFn: async ({ pageParam = 1 }) => { // pageParam의 형식을 직접 지정
const response = await apis.getProduct(pageParam as number);
return response.data.results;
},
getNextPageParam: (lastPage, pages): number | false => {
const nextPage = pages.length + 1;
return lastPage.length === 0 ? false : nextPage;
},
initialPageParam: 1
});
console.log("데이터", data)
const target = useInfiniteScroll({
hasNextPage,
fetchNextPage,
})
return (
<Container>
{
data?.pages.flat().map((p, i) => {
return <div key={p.product_id}>
<LazyLoadingImage
src={p.image}
onError={(e: React.ChangeEvent<HTMLImageElement>) => {
e.target.onerror = null; // 에러 핸들러 무한 루프 방지
e.target.src = p.image // 이미지 로드 실패 시 p.image 사용
e.target.width = 380;
e.target.height = 380;
}}
alt={p.product_name}
onClick={() => navigate(`/detail/${p.product_id}`)}
placeholderImg={PlaceholderImg}
/>
<p className='product-name'>{p.store_name}</p>
<p className='product'>{p.product_name}</p>
<span className='product-price'>{p.price.toLocaleString()}</span>
<span>원</span>
</div>
})
}
{hasNextPage ? <div ref={target}></div> : null}
</Container>
)
}
const Container = styled.div`
width: 100%;
display: grid;
grid-template-columns:repeat(3,380px);
justify-content: center;
gap: 70px;
padding: 80px 0 180px;
@media screen and (max-width:1300px) {
grid-template-columns:repeat(2,380px);
}
@media screen and (max-width:932px) {
grid-template-columns:repeat(1,380px);
}
div {
cursor: pointer;
}
img {
margin-bottom: 16px;
/* border: 1px solid #C4C4C4; */
border-radius: 10px;
/* width: 380px; */
height: 380px;
}
.product-name {
font-size: 16px;
color: #767676;
}
.product {
font-size: 18px;
margin: 10px 0;
}
.product-price {
font-size: 24px;
font-weight: bold;
}
`
export default MainGrid
우선, initialPageParam을 1로 설정해뒀다. 그럼 pageParam의 초기 값을 1로 설정했다는 뜻이다. 그럼 pageParam이 1인 data를 받아오게 되고 이 data는 data.pages에 담기고 getNextPageParam옵션을 통해서 lastPage(최신 데이터)의 length가 0이면 false가 반환되고, 그렇지 않으면 allPage의 length에 1을 더한 값을 다음 pateParam값으로 반환되게 한다.
const target = useInfiniteScroll({
hasNextPage,
fetchNextPage,
})
무한스크롤 hook인 useInfiniteScroll에 hasNextPage와 fetchNextPage를 전달한다.
✨ fetchNextPage 함수를 호출시 작동순서:
data?.pages.flat().map()에서 flat()은 다차원 배열을 평탄화하여 1차원 배열로 만드는 JavaScript 배열 메서드이다.
여기서 data?.pages는 useInfiniteQuery에서 반환된 페이지 데이터를 나타내는 배열이다. 각 페이지마다 배열이 하나씩 있고, 각 배열은 해당 페이지에 있는 데이터를 포함한다.
만약 데이터가 페이지별로 나누어져 있는 경우, flat() 메서드를 사용하여 이러한 다차원 배열을 하나의 평탄한 배열로 만들 수 있다. 그 후에 map() 메서드를 사용하여 각 데이터를 순회하고 화면에 표시하게끔 한다.
따라서 data?.pages.flat().map()은 모든 페이지에서 가져온 데이터를 하나의 배열로 합쳐서 각 데이터를 순회하고 화면에 표시하기 위해 사용된다.
import { InfiniteQueryObserverResult } from '@tanstack/react-query';
import { useCallback, useEffect, useRef } from 'react';
type IntersectionObserverProps = {
hasNextPage: boolean | false;
fetchNextPage: () => Promise<InfiniteQueryObserverResult>
}
function useInfiniteScroll({ hasNextPage, fetchNextPage }: IntersectionObserverProps) {
const ref = useRef<HTMLDivElement>(null);
const handleIntersect: IntersectionObserverCallback = useCallback(([entry]: IntersectionObserverEntry[], observer: IntersectionObserver) => {
if (entry?.isIntersecting && hasNextPage) {
fetchNextPage();
}
}, [fetchNextPage, hasNextPage]);
useEffect(() => {
let observer: IntersectionObserver;
if (ref.current) {
observer = new IntersectionObserver(handleIntersect, { threshold: 0.6, });
observer.observe(ref.current);
}
return () => observer && observer.disconnect();
}, [ref, handleIntersect]);
return ref;
}
export default useInfiniteScroll
entry는 관찰 중인 요소의 정보를 포함하는 객체이고, isIntersecting은 entry 속성 중 하나로, 해당 요소가 현재 뷰포트와 교차(intersect)하는지 여부를 나타내는 boolean 값이다.
간단히 말해, entry.isIntersecting이 true이면 관찰 중인 요소가 현재 화면에 보이고 있다는 것을 의미하며, false이면 요소가 화면에 보이지 않는다는 것을 나타낸다. 이 값을 사용하여 화면 스크롤과 관련된 작업을 수행할 수 있다.
Main페이지에서 전달해준 hasNextPage, fetchNextPage를 전달 받아서, target이 현재 뷰포트와 교차되고 있고, hasNextPage(다음 페이지의 여부)가 true이면 fetchNextPage함수를 호출하게 해준다. 그럼 useInfiniteQuery의 쿼리함수가 실행되고 데이터를 뷰포트에 교차하고 다음 페이지가 존재한다면 계속해서 데이터를 불러오게 되고 무한스크롤 기능을 수행하게 된다.