[ TanStack Query ] TanStack Query로 Infinite scroll 기능 구현하기 (with React, TypeScript)

 

서론

작년 취업 준비할 때 React Query를 공부할 때까지만 해도 공식 이름이 React Query 였는데... 
이제는 TanStack Query 라는 새로운 친구가 되어버렸다 😂

 

기존에 공부했던 v3인 React Query 대신, 발 빠르게 업데이트된 TanStack Query로 무한 스크롤 기능을 한 번 구현해보고자 한다. (내 코드를 빠르게 레거시로 만들기는 싫어 🥺)

 

Infinite Queries

공식 문서

https://tanstack.com/query/v4/docs/react/guides/infinite-queries

 

Infinite Queries | TanStack Query Docs

Rendering lists that can additively "load more" data onto an existing set of data or "infinite scroll" is also a very common UI pattern. TanStack Query supports a useful version of useQuery called useInfiniteQuery for querying these types of lists. When us

tanstack.com

 

기존 데이터에 "더 불러오기" 또는 "무한 스크롤"을 추가할 수 있는 목록을 렌더링하는 것은 매우 흔한 UI 패턴입니다. 

TanStack Query는 이러한 유형의 목록을 쿼리하는 유용한 useQuery 버전인 useInfiniteQuery를 지원합니다.

 

useInfiniteQuery를 사용할 때 몇 가지 다른 점을 알 수 있습니다:

  • data는 이제 무한 쿼리 데이터를 포함하는 객체입니다.
    • data.pages:  가져온 페이지를 담은 배열
    • data.pageParams: 페이지를 가져오는 데 사용된 페이지 매개 변수를 담은 배열
  • fetchNextPagefetchPreviousPage 함수가 이제 사용 가능합니다.
  • getNextPageParamgetPreviousPageParam 옵션은 더 많은 데이터를 로드하고 해당 정보를 가져 오기 위한 정보를 결정하기 위해 사용할 수 있습니다. 이 정보는 쿼리 함수의 추가 매개 변수로 제공됩니다. (fetchNextPage 또는 fetchPreviousPage 함수를 호출할 때 선택적으로 재정의 가능)
  • hasPreviousPage boolean이 이제 사용 가능하며 getPreviousPageParamundefined 이외의 값을 반환하면 true 입니다.
  • isFetchingNextPageisFetchingPreviousPage boolean은 background refresh state와 loading more state를 구별하는 데 사용할 수 있습니다.
쿼리에서 initialData 또는 select와 같은 옵션을 사용할 때 데이터를 다시 구성할 때 여전히 data.pagesdata.pageParams 속성이 포함되어 있는지 확인하십시오. 그렇지 않으면 변경 사항이 반환된 쿼리에 덮어씌워집니다.

 

예시 

한 번에 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 함수를 호출합니다.
fetchNextPage에 인수를 지정하지 않는 한, getNextPageParam 함수에서 반환된 pageParam 데이터를 무시하지 않도록 하는 것이 매우 중요합니다.
ex) <button onClick={fetchNextPage} /> 이렇게 하지 마세요. 그렇게 하면 onClick 이벤트가 fetchNextPage 함수로 전송됩니다.
import { useInfiniteQuery } from '@tanstack/react-query'

function Projects() {
  const fetchProjects = async ({ pageParam = 0 }) => {
    const res = await fetch('/api/projects?cursor=' + pageParam)
    return res.json()
  }

  const {
    data,
    error,
    fetchNextPage,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  })

  return status === 'loading' ? (
    <p>Loading...</p>
  ) : status === 'error' ? (
    <p>Error: {error.message}</p>
  ) : (
    <>
      {data.pages.map((group, i) => (
        <React.Fragment key={i}>
          {group.projects.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>
    </>
  )
}

 

무한 쿼리를 다시 가져와야 하는 경우

무한 쿼리가 만료되고 다시 가져와야 할 때는, 각 그룹이 처음부터 순차적으로 가져옵니다. 이렇게 하면 기본 데이터가 변경되더라도 만료된 커서를 사용하지 않고 중복 데이터를 가져오지 않거나 누락된 레코드를 건너 뛸 가능성이 없습니다. 무한 쿼리의 결과가 쿼리 캐시에서 제거되면, 초기 상태에서 시작하면 초기 그룹만 요청합니다.

 

refetchPage

모든 페이지 중 일부만 활성적으로 다시 가져오려면 useInfiniteQuery에서 반환된 refetchrefetchPage 함수를 전달할 수 있습니다.

const { refetch } = useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})

// only refetch the first page
refetch({ refetchPage: (page, index) => index === 0 })

이 함수를 queryClient.refetchQueries, queryClient.invalidateQueries, queryClient.resetQueries의 두 번째 인자(queryFilters)로 전달할 수도 있습니다.

 

Signature

refetchPage: (page: TData, index: number, allPages: TData[]) => boolean

이 함수는 각 페이지에 대해 실행되며, 이 함수가 true를 반환하는 페이지만 다시 가져옵니다.

 

쿼리 함수에 사용자 지정 정보를 전달해야 할 경우

기본적으로 getNextPageParam에서 반환된 변수가 쿼리 함수에 제공됩니다. 하지만 경우에 따라 이를 재정의해야 할 수도 있습니다. 이 경우 fetchNextPage 함수에 사용자 지정 변수를 전달하여 기본 변수를 덮어쓸 수 있습니다.

function Projects() {
  const fetchProjects = ({ pageParam = 0 }) =>
    fetch('/api/projects?cursor=' + pageParam)

  const {
    status,
    data,
    isFetching,
    isFetchingNextPage,
    fetchNextPage,
    hasNextPage,
  } = useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  })

  // Pass your own page param
  const skipToCursor50 = () => fetchNextPage({ pageParam: 50 })
}

양방향으로 무한 리스트를 구현할 경우

양방향 리스트는 getPreviousPageParam, fetchPreviousPage, hasPreviousPage, isFetchingPreviousPage 속성과 함수를 사용하여 구현할 수 있습니다.

useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  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,
}))

페이지와 페이지 파라미터의 동일한 데이터 구조를 유지하도록 주의하세요!

 

useInfiniteQuery

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

options

useInfiniteQuery의 옵션은 useQuery Hook과 동일하지만 다음과 같은 추가 옵션이 있습니다.

  • queryFn: (context: QueryFunctionContext) => Promise<TData>
    • defaultQueryFn이 정의되어 있지 않은 경우 필수입니다.
    • 쿼리가 데이터를 요청할 때 사용할 함수입니다.
    • QueryFunctionContext를 받습니다.
    • 데이터를 resolve 하거나 오류를 throw할 프로미스를 반환해야 합니다.
    • 필요한 경우 데이터와 페이지 매개 변수를 반환해야 합니다.
  • getNextPageParam: (lastPage, allPages) => unknown | undefined
    • 이 쿼리에 대한 새 데이터를 받으면 이 함수는 데이터의 무한 리스트의 마지막 페이지와 모든 페이지의 전체 배열을 모두 받습니다.
    • 쿼리 함수의 마지막 선택적 매개 변수로 전달될 단일 변수를 반환해야 합니다.
    • 다음 페이지가 없음을 나타내려면 undefined를 반환하십시오.
  • getPreviousPageParam: (firstPage, allPages) => unknown | undefined
    • 이 쿼리에 대한 새 데이터를 받으면 이 함수는 데이터의 무한 리스트의 첫 번째 페이지와 모든 페이지의 전체 배열을 모두 받습니다.
    • 쿼리 함수의 마지막 선택적 매개 변수로 전달될 단일 변수를 반환해야 합니다.
    • 이전 페이지가 없음을 나타내려면 undefined를 반환하십시오.

returns

useInfiniteQuery의 반환값은 useQuery 훅과 동일하며, 다음과 같은 속성이 추가됩니다.

또한 isRefetching에서 약간 차이점이 있습니다.

  • data.pages: TData[]
    • 모든 페이지를 포함하는 배열입니다.
  • data.pageParams: unknown[]
    • 모든 페이지 매개변수를 포함하는 배열입니다.
  • isFetchingNextPage: boolean
    • fetchNextPage로 다음 페이지를 가져오는 동안 true가 됩니다.
  • isFetchingPreviousPage: boolean
    • fetchPreviousPage로 이전 페이지를 가져오는 동안 true가 됩니다.
  • fetchNextPage: (options?: FetchNextPageOptions) => Promise<UseInfiniteQueryResult>
    • 다음 "페이지" 결과를 가져올 수 있습니다.
    • options.pageParam: unknowngetNextPageParam을 사용하는 대신 수동으로 페이지 매개변수를 지정할 수 있습니다.
    • options.cancelRefetch: booleantrue로 설정하면 fetchNextPage를 반복해서 호출하면 이전 호출이 해결되었는지 여부와 상관없이 fetchPage가 매번 호출됩니다. 또한 이전 호출의 결과는 무시됩니다. false로 설정하면 fetchNextPage를 반복해서 호출해도 첫 번째 호출이 해결될 때까지 아무런 영향도 주지 않습니다. 기본값은 true입니다.
  • fetchPreviousPage: (options?: FetchPreviousPageOptions) => Promise<UseInfiniteQueryResult>
    • 이전 "페이지" 결과를 가져올 수 있습니다.
    • option.pageParam: unknowngetPreviousPageParam을 사용하는 대신 수동으로 페이지 매개변수를 지정할 수 있습니다.
    • options.cancelRefetch: fetchNextPage와 같습니다.
  • hasNextPage: boolean
    • 가져올 다음 페이지가 있는 경우 true 입니다 (getNextPageParam 옵션을 통해 알 수 있음)
  • hasPreviousPage: boolean
    • 가져올 이전 페이지가 있는 경우 true 입니다 (getPreviousPageParam 옵션을 통해 알 수 있음)
  • isRefetching: boolean
    • background refetch가 진행 중일 때 true입니다. 초기 로딩이나 다음/이전 페이지를 가져오는 것은 포함되지 않습니다.
    • isFetching && !isLoading && !isFetchingNextPage && !isFetchingPReviousPage과 동일합니다.

 

useInfiniteQuery 예시 코드

https://tanstack.com/query/v4/docs/react/examples/react/load-more-infinite-scroll

 

React Query Load More Infinite Scroll Example | TanStack Query Docs

An example showing how to implement Load More Infinite Scroll in React Query

tanstack.com

 

 

헤맸던 부분

useInfiniteQuery 예시 코드를 비지니스 코드에 적용을 해도 계속 page=1 에 대한 데이터만 불러오고, 추가적인 데이터가 불러지지 않았다.

 

  const { data, isLoading, isError, isFetchingNextPage, fetchNextPage } =
    useInfiniteQuery(
      ["getNoticeList"],
      async ({ pageParam = 1 }) => {                
        return getNoticeList(pageParam);
      },
      {
        getPreviousPageParam: (firstPage) => firstPage.previousId ?? undefined,
        getNextPageParam: (lastPage) => lastPage.nextId ?? undefined,
      }
    );

getNoticeList에 매개변수로 들어가는 pageParam이 계속 undefined 였던 것이다.

pageParam 을 결정해주는 것은 getNextPageParam 함수의 반환값이었다.

 

그래서 다음과 같이 변경해주었다.

  const { data, isLoading, isError, isFetchingNextPage, fetchNextPage } =
    useInfiniteQuery(
      ["getNoticeList"],
      async ({ pageParam = 1 }) => {
        return getNoticeList(pageParam);
      },
      {
        getPreviousPageParam: (firstPage) => firstPage.previousId ?? undefined,
        getNextPageParam: (lastPage) => 
        lastPage.page.next ? lastPage.page.next.split("=").pop() : undefined,
      }
    );

(이전으로 넘어가는 기능을 구현하기 전이기 때문에 getNextPageParam만 보자.)

여기서 lastPage 객체는 getNoticeList에서 반환되는 값이다.

API 에서 반환되는 객체의 프로퍼티 이름을 잘 보고 코드를 변경하면 된다.

 

위처럼 split().pop() 을 했을 때 '10' 이 '0' 으로 되는 경우가 있는데, 해당 경우는 getNoticeList 함수에서 예외 처리를 진행하였다.

next가 없을 경우에는 undefined 를 반환하도록 해주었다.

 

이렇게 무한 스크롤 기능 성공~!!

 

이 문제를 해결하는 데에 힌트를 얻을 수 있었던 블로그 글입니다.

https://jforj.tistory.com/246

 

[React] React Query의 useInfiniteQuery에 대해 알아보기

안녕하세요. J4J입니다. 이번 포스팅은 React Query의 useInfiniteQuery에 대해 적어보는 시간을 가져보려고 합니다. useInfiniteQuery란? useInfiniteQuery는 파라미터 값만 변경하여 동일한 useQuery를 무한정 호출

jforj.tistory.com