[react-query] useInfiniteQuery

이춘구·2022년 6월 17일
24

translation

목록 보기
1/13
post-custom-banner

Tanstack Query 공식문서의 Infinite Queries를 번역한 글입니다.

업데이트: 24.05.15


Infinite Queries

기존 데이터에 더해 데이터를 "더 불러오기" 또는 "무한 스크롤" 할 수 있는 목록을 렌더링하는 것도 아주 일반적인 UI 패턴입니다. TanStack Query는 이런 유형의 목록을 쿼리하는 용도로, useQuery의 유용한 버전인 useInfiniteQuery를 지원합니다.

useInfiniteQuery를 사용할 때, 여러분은 몇 가지 다른 점을 알아차릴 것입니다.

  • data는 infinite query 데이터가 담겨있는 객체입니다.
  • data.pages은 fetch한 페이지가 담겨있는 배열입니다.
  • data.pageParams는 페이지를 fetch하는 데 사용되는 page param이 담겨있는 배열입니다.
  • fetchNextPagegetPreviousPageParam를 사용할 수 있습니다(fetchNextPage는 필수).
  • initialPageParam 옵션으로 초기 page param을 지정할 수 있습니다(필수).
  • getNextPageParamgetPreviousPageParam 옵션으로 로드할 데이터가 더 있는지 여부와 fetch할 정보를 결정할 수 있습니다. 해당 정보는 query 함수에 추가 매개 변수로 제공됩니다.
  • hasNextPage를 사용할 수 있으며 getNextPageParamnull 또는 undefined 이외의 값을 반환하는 경우 hasNextPagetrue입니다.
  • hasPreviousPage를 사용할 수 있으며 getPreviousPageParamnull 또는 undefined 이외의 값을 반환하는 경우 hasPreviousPagetrue입니다.
  • isFetchingNextPageisFetchPreviousPage로 백그라운드 새로고침 상태와 추가 로딩 중 상태를 구볗할 수 있습니다.

주의: initialData 또는 placeholderDatadata.pagesdata.pageParams 속성과 동일한 구조의 객체여야 합니다.

Example

다음 프로젝트 그룹을 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가 refetch 되어야 하면 무슨 일이 일어나죠?

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(),
  }),
})

infinite query를 직접 업데이트 하고 싶으면요?

첫번째 페이지를 직접 제거하기

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),
}))

pagespageParams의 데이터 구조는 항상 동일하게 유지하세요!

페이지 수를 제한하고 싶으면요?

아래 몇가지 사례처럼 쿼리 데이터에 저장되는 페이지 수를 제한해서 성능과 UX를 개선하고 싶을 수 있습니다.

  • 사용자가 다수의 페이지를 불러올 수 있는 경우 (메모리 사용량)
  • 페이지를 많이 담고 있는 infinite query를 refetch해야하는 경우 (네트워크 사용량: 모든 페이지가 순차적으로 fetch 됨)

해결책은 "Limited Infinite Query"입니다. 이것은 maxPages 옵션을 getNextPageParamgetPreviousPageParam과 함께 사용해서 필요시 양방향으로 페이지를 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가 커서를 반환하지 않으면요?

API가 커서를 반환하지 않으면 pageParam을 커서로 사용할 수 있습니다. getNextPageParamgetPreviousPageParam도 현재 페이지의 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
  },
})
profile
프런트엔드 개발자
post-custom-banner

0개의 댓글