무엇
useInfiniteQuery는 여러 페이지를 이어 붙여 가져오는 쿼리다. 하나의 쿼리 키 아래에 페이지들을 쌓아 두고, 다음 페이지를 요청할 때마다 그 목록을 늘린다.
useQuery의 확장이라 상태·캐시 개념은 같고, 페이지네이션 옵션이 더 붙는다. v5에서는 initialPageParam(첫 페이지 파라미터)과 getNextPageParam(다음 파라미터 계산)이 둘 다 필수다.
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchPage(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
무슨 일이 일어나나
data의 모양이 useQuery와 다르다. 단일 값이 아니라 { pages, pageParams }다. pages는 각 페이지의 결과 배열이고, pageParams는 각 페이지를 가져올 때 쓴 파라미터 배열이다. 화면에 뿌릴 때는 보통 data.pages.flatMap(...)으로 평탄화한다.
queryFn은 QueryFunctionContext에서 pageParam을 받는다. 첫 호출의 pageParam은 initialPageParam이고, 그다음부터는 getNextPageParam(또는 getPreviousPageParam)이 돌려준 값이다.
getNextPageParam(lastPage, allPages, lastPageParam, allPageParams)이 다음 파라미터를 반환하면 hasNextPage가 true가 되고, undefined나 null을 반환하면 더 가져올 페이지가 없다는 뜻이라 hasNextPage가 false가 된다. 마지막 페이지를 판별하는 책임이 전적으로 이 함수에 있다.
fetchNextPage로 뒤에 붙이고, getPreviousPageParam을 함께 주면 fetchPreviousPage로 앞에 붙일 수도 있다(양방향). 페이지가 무한정 쌓이는 걸 막으려면 maxPages(기본 undefined = 무제한)로 보관 개수를 제한한다.
사용법
data.pages를 펼쳐 렌더하고, 버튼으로 fetchNextPage를 부른다. 진행 중 상태는 isFetchingNextPage로 구분한다(전체 isFetching과 다르다).
import { useInfiniteQuery } from '@tanstack/react-query'
export function Projects() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isPending,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})
if (isPending) return <p>로딩 중</p>
return (
<div>
{data.pages.flatMap((page) => page.items).map((p) => (
<div key={p.id}>{p.name}</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? '불러오는 중' : hasNextPage ? '더 보기' : '끝'}
</button>
</div>
)
}
실무 예시
커서 기반 API에서 무한 스크롤을 만드는 경우다. 서버가 다음 커서를 주면 그걸 다음 pageParam으로 넘기고, 더 없으면 null을 반환해 끝을 알린다. maxPages로 메모리에 유지할 페이지를 제한한다.
export function useFeed() {
return useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }: { pageParam: string | null }) =>
fetchFeed({ cursor: pageParam }),
initialPageParam: null,
// 서버 응답의 nextCursor가 null이면 여기서 멈춘다
getNextPageParam: (lastPage) => lastPage.nextCursor,
maxPages: 5, // 오래된 페이지는 버려 메모리 관리
})
}
왜 중요한가
무한 스크롤과 "더 보기"는 흔한 요구지만 손으로 짜면 페이지 누적·중복 요청·끝 판별·캐시 일관성이 금방 엉킨다. useInfiniteQuery는 이걸 getNextPageParam 하나로 정리하고, 나머지(누적·상태·리페치)를 useQuery와 같은 방식으로 관리한다. 페이지네이션이 없는 화면엔 쓸 일이 없어 useQuery만큼 자주는 아니지만, 목록 UI에선 사실상 표준 도구다.
Reference