서론
작년 취업 준비할 때 React Query를 공부할 때까지만 해도 공식 이름이 React Query 였는데...
이제는 TanStack Query 라는 새로운 친구가 되어버렸다 😂
기존에 공부했던 v3인 React Query 대신, 발 빠르게 업데이트된 TanStack Query로 무한 스크롤 기능을 한 번 구현해보고자 한다. (내 코드를 빠르게 레거시로 만들기는 싫어 🥺)
Infinite Queries
공식 문서
https://tanstack.com/query/v4/docs/react/guides/infinite-queries
기존 데이터에 "더 불러오기" 또는 "무한 스크롤"을 추가할 수 있는 목록을 렌더링하는 것은 매우 흔한 UI 패턴입니다.
TanStack Query는 이러한 유형의 목록을 쿼리하는 유용한 useQuery
버전인 useInfiniteQuery
를 지원합니다.
useInfiniteQuery
를 사용할 때 몇 가지 다른 점을 알 수 있습니다:
data
는 이제 무한 쿼리 데이터를 포함하는 객체입니다.data.pages
: 가져온 페이지를 담은 배열data.pageParams
: 페이지를 가져오는 데 사용된 페이지 매개 변수를 담은 배열
fetchNextPage
및fetchPreviousPage
함수가 이제 사용 가능합니다.getNextPageParam
및getPreviousPageParam
옵션은 더 많은 데이터를 로드하고 해당 정보를 가져 오기 위한 정보를 결정하기 위해 사용할 수 있습니다. 이 정보는 쿼리 함수의 추가 매개 변수로 제공됩니다. (fetchNextPage
또는fetchPreviousPage
함수를 호출할 때 선택적으로 재정의 가능)hasPreviousPage
boolean이 이제 사용 가능하며getPreviousPageParam
이undefined
이외의 값을 반환하면true
입니다.isFetchingNextPage
및isFetchingPreviousPage
boolean
은 background refresh state와 loading more state를 구별하는 데 사용할 수 있습니다.
쿼리에서initialData
또는select
와 같은 옵션을 사용할 때 데이터를 다시 구성할 때 여전히data.pages
및data.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
에서 반환된 refetch
에 refetchPage
함수를 전달할 수 있습니다.
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: unknown
은getNextPageParam
을 사용하는 대신 수동으로 페이지 매개변수를 지정할 수 있습니다.options.cancelRefetch: boolean
을true
로 설정하면fetchNextPage
를 반복해서 호출하면 이전 호출이 해결되었는지 여부와 상관없이fetchPage
가 매번 호출됩니다. 또한 이전 호출의 결과는 무시됩니다.false
로 설정하면fetchNextPage
를 반복해서 호출해도 첫 번째 호출이 해결될 때까지 아무런 영향도 주지 않습니다. 기본값은true
입니다.
fetchPreviousPage: (options?: FetchPreviousPageOptions) => Promise<UseInfiniteQueryResult>
- 이전 "페이지" 결과를 가져올 수 있습니다.
option.pageParam: unknown
은getPreviousPageParam
을 사용하는 대신 수동으로 페이지 매개변수를 지정할 수 있습니다.options.cancelRefetch: fetchNextPage
와 같습니다.
hasNextPage: boolean
- 가져올 다음 페이지가 있는 경우
true
입니다 (getNextPageParam
옵션을 통해 알 수 있음)
- 가져올 다음 페이지가 있는 경우
hasPreviousPage: boolean
- 가져올 이전 페이지가 있는 경우
true
입니다 (getPreviousPageParam
옵션을 통해 알 수 있음)
- 가져올 이전 페이지가 있는 경우
isRefetching: boolean
- background refetch가 진행 중일 때
true
입니다. 초기 로딩이나 다음/이전 페이지를 가져오는 것은 포함되지 않습니다. isFetching && !isLoading && !isFetchingNextPage && !isFetchingPReviousPage
과 동일합니다.
- background refetch가 진행 중일 때
useInfiniteQuery 예시 코드
https://tanstack.com/query/v4/docs/react/examples/react/load-more-infinite-scroll
헤맸던 부분
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
를 반환하도록 해주었다.
이렇게 무한 스크롤 기능 성공~!!
이 문제를 해결하는 데에 힌트를 얻을 수 있었던 블로그 글입니다.
'React' 카테고리의 다른 글
[ Storybook for Vite ] React Vite TypeScript 프로젝트에 Storybook 도입하기 (0) | 2023.04.14 |
---|---|
[ React ] 정적 파일을 public 폴더에 관리하기 vs src 폴더에 관리하기 (0) | 2023.03.27 |
[ React + TypeScript + Vite ] public 경로 단순하게 설정하는 방법 (0) | 2023.03.27 |
[Context vs Recoil] 컴포넌트 상태 관리 하기 (0) | 2023.02.23 |
[React Vite] CRA에서 Vite로 마이그레이션(migration) 하기 (0) | 2023.01.31 |
Comment