Tanstack Query 공식문서의 Infinite Queries를 번역한 글입니다.
업데이트: 24.05.15
기존 데이터에 더해 데이터를 "더 불러오기" 또는 "무한 스크롤" 할 수 있는 목록을 렌더링하는 것도 아주 일반적인 UI 패턴입니다. TanStack Query는 이런 유형의 목록을 쿼리하는 용도로, useQuery
의 유용한 버전인 useInfiniteQuery
를 지원합니다.
useInfiniteQuery
를 사용할 때, 여러분은 몇 가지 다른 점을 알아차릴 것입니다.
data
는 infinite query 데이터가 담겨있는 객체입니다.data.pages
은 fetch한 페이지가 담겨있는 배열입니다.data.pageParams
는 페이지를 fetch하는 데 사용되는 page param이 담겨있는 배열입니다.fetchNextPage
와 getPreviousPageParam
를 사용할 수 있습니다(fetchNextPage
는 필수).initialPageParam
옵션으로 초기 page param을 지정할 수 있습니다(필수).getNextPageParam
와 getPreviousPageParam
옵션으로 로드할 데이터가 더 있는지 여부와 fetch할 정보를 결정할 수 있습니다. 해당 정보는 query 함수에 추가 매개 변수로 제공됩니다.hasNextPage
를 사용할 수 있으며 getNextPageParam
이 null
또는 undefined
이외의 값을 반환하는 경우 hasNextPage
는 true
입니다.hasPreviousPage
를 사용할 수 있으며 getPreviousPageParam
이 null
또는 undefined
이외의 값을 반환하는 경우 hasPreviousPage
는 true
입니다.isFetchingNextPage
와 isFetchPreviousPage
로 백그라운드 새로고침 상태와 추가 로딩 중 상태를 구볗할 수 있습니다.주의:
initialData
또는placeholderData
는data.pages
및data.pageParams
속성과 동일한 구조의 객체여야 합니다.
다음 프로젝트 그룹을 fetch하는 데 사용할 수 있는 커서 인덱스인 cursor
를 기반으로, 요청 한 번에 project
페이지 3개를 반환하는 API를 가정해보겠습니다.
fetch('/api/projects?cursor=0')
// { data: [...], nextCursor: 3}
fetch('/api/projects?cursor=3')
// { data: [...], nextCursor: 6}
fetch('/api/projects?cursor=6')
// { data: [...], nextCursor: 9}
fetch('/api/projects?cursor=9')
// { data: [...] }
이 정보와 함께 아래의 과정을 거쳐 "더 불러오기" UI를 만들 수 있습니다.
useInfiniteQuery
가 기본적으로 첫번째 데이터 그룹을 요청하는 걸 기다리기getNextPageParam
으로 다음 쿼리에서 사용할 정보 반환하기fetchNextPage
함수 호출하기import { useInfiniteQuery } from '@tanstack/react-query'
function Projects() {
const fetchProjects = async ({ pageParam }) => {
const res = await fetch('/api/projects?cursor=' + pageParam)
return res.json()
}
const {
data,
error,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
status,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})
return status === 'pending' ? (
<p>Loading...</p>
) : status === 'error' ? (
<p>Error: {error.message}</p>
) : (
<>
{data.pages.map((group, i) => (
<React.Fragment key={i}>
{group.data.map((project) => (
<p key={project.id}>{project.name}</p>
))}
</React.Fragment>
))}
<div>
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'Nothing more to load'}
</button>
</div>
<div>{isFetching && !isFetchingNextPage ? 'Fetching...' : null}</div>
</>
)
}
fetch가 진행되는 중에 fetchNextPage
를 호출하면 백그라운드에서 새로고침되는 데이터를 덮어쓸 위험이 있음을 필수적으로 이해해야 합니다. 이 상황은 목록을 렌더링하면서 동시에 fetchNextPage
트리거할 때 특히 치명적이게 됩니다.
하나의 InfiniteQuery에게 진행 중인 fetch는 하나만 존재할 수 있다는 것을 기억하세요. 캐시 엔트리 하나가 모든 페이지에 공유되므로 fetch를 동시에 두 번 시도하면 데이터를 덮어쓸 수 있습니다.
동시 fetch를 가능하게 하려는 의도라면 fetchNextPage
내에서 { cancelRefetch: false }
옵션(기본값: true)을 활용할 수 있습니다.
쿼리가 충돌없이 원활하게 처리되는 것을 보장하려면 쿼리의 상태가 isFetching
이 아닌지 확인할 것을 강력히 추천하며, 특히 사용자가 해당 호출을 직접 제어하지 않을 경우가 이에 해당합니다.
<List onEndReached={() => !isFetching && fetchNextPage()} />
infinite query의 상태가 stale
이 되고 refetch 되어야 한다면 각 데이터 그룹은 첫번째 그룹부터 순차적으로
fetch 됩니다. 이 동작은 만일 근본적인 데이터가 변형되더라도, 우리가 오래된 커서를 사용하는 게 아니며 중복 데이터를 받거나 레코드를 건너뛸 가능성이 없음을 보장해줍니다. infinite query의 결과가 queryCache에서 제거되기라도 한다면 페이지네이션은 요청되는 첫 데이터 그룹만 존재하는 초기 상태에서 다시 시작됩니다.
양방향 목록은 getPreviousPageParam
, fetchPreviousPage
, hasPreviousPage
, isFetchingPreviousPage
를 사용해서 구현할 수 있습니다.
useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
})
페이지를 역순으로 보여주고 싶으면 select
옵션을 사용하면 됩니다.
useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
select: (data) => ({
pages: [...data.pages].reverse(),
pageParams: [...data.pageParams].reverse(),
}),
})
queryClient.setQueryData(['projects'], (data) => ({
pages: data.pages.slice(1),
pageParams: data.pageParams.slice(1),
}))
const newPagesArray =
oldPagesArray?.pages.map((page) =>
page.filter((val) => val.id !== updatedId),
) ?? []
queryClient.setQueryData(['projects'], (data) => ({
pages: newPagesArray,
pageParams: data.pageParams,
}))
queryClient.setQueryData(['projects'], (data) => ({
pages: data.pages.slice(0, 1),
pageParams: data.pageParams.slice(0, 1),
}))
pages
와 pageParams
의 데이터 구조는 항상 동일하게 유지하세요!
아래 몇가지 사례처럼 쿼리 데이터에 저장되는 페이지 수를 제한해서 성능과 UX를 개선하고 싶을 수 있습니다.
해결책은 "Limited Infinite Query"입니다. 이것은 maxPages
옵션을 getNextPageParam
및 getPreviousPageParam
과 함께 사용해서 필요시 양방향으로 페이지를 fetch할 수 있도록 함으로써 가능해졌습니다.
아래 예시에서 3개 페이지만 쿼리 데이터 페이지 배열에 유지됩니다. refetch가 필요하다면 3개 페이지만 순차적으로 refetch 됩니다.
useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
maxPages: 3,
})
API가 커서를 반환하지 않으면 pageParam
을 커서로 사용할 수 있습니다. getNextPageParam
과 getPreviousPageParam
도 현재 페이지의 pageParam
을 가져오기 때문에, 이전/다음 page param을 계산하는 데 사용할 수 있습니다.
return useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
if (lastPage.length === 0) {
return undefined
}
return lastPageParam + 1
},
getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
if (firstPageParam <= 1) {
return undefined
}
return firstPageParam - 1
},
})