TanStack Query가 최근 10월 중 v5 버전을 정식 출시하였는데요, v4에 비해 번들 크기를 20% 줄이고 제공하는 API를 간소화하는데 중점을 두었다고 합니다. 따라서 v5의 주요 변경 및 개선 사항들에 대해 살펴보겠습니다.
v5에서 useQuery
를 비롯한 함수들은 기존과 달리 옵션들이 정의된 단일 객체를 전달받아 실행합니다.
그 중 첫 번째 인자인 queryKey
, queryFn
이 필수 값이며 이 후 다양한 옵션들을 작성합니다.
- useQuery(key, fn, options)
+ useQuery({ queryKey, queryFn, ...options })
- useInfiniteQuery(key, fn, options)
+ useInfiniteQuery({ queryKey, queryFn, ...options })
- useMutation(fn, options)
+ useMutation({ mutationFn, ...options })
- queryClient.invalidateQueries(key, filters, options)
+ queryClient.invalidateQueries({ queryKey, ...filters }, options)
이전에는 queryKey
만 필수 옵션이고 다양한 형태로 작성할 수 있었기에 일관성이 떨어지는 점, 사용될 옵션을 생성할 때 첫 번째와 두 번째 매개변수의 타입이 무엇인지 확인하기 위해 런타임 체크가 필요한 점 등을 이유로 이와 같이 변경되었습니다.
v4까지 있던 onSuccess
, onError
, onSettled
Callback은 useQuery
옵션에서 이제 사용되지 않습니다.
콜백을 제거한 이유는 다음과 같습니다:
useQuery
onSuccess
콜백에 로컬 또는 전역 상태 업데이트export function useTodos() {
const [todoCount, setTodoCount] = React.useState(0)
const { data: todos } = useQuery({
queryKey: ['todos', 'list'],
queryFn: fetchTodos,
//😭 please don't
onSuccess: (data) => {
setTodoCount(data.length)
},
})
return { todos, todoCount }
}
staleTime
설정으로 query function이 호출되지 않아 의도한 콜백이 실행되지 않을 경우export function useTodos() {
const { dispatch } = useDispatch()
return useQuery({
queryKey: ['todos', 'list'],
queryFn: fetchTodos,
onSuccess: (data) => {
dispatch(setTodos(data))
},
})
}
따라서, v5가 정식으로 출시되고 나서부터는 콜백을 다은과 같은 방법으로 다룰 것을 제시합니다:
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) =>
toast.error(`Something went wrong: ${error.message}`),
}),
})
function TodoList() {
// ✅ will propagate all fetching errors
// to the nearest Error Boundary
const todos = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
throwOnError: true,
})
if (todos.data) {
return (
<div>
{todos.data.map((todo) => (
<Todo key={todo.id} {...todo} />
))}
</div>
)
}
return 'Loading...'
}
isError
등으로 컴포넌트 내에서 처리function TodoList() {
const todos = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos
})
if (todos.isPending) {
return 'Loading...'
}
// ✅ standard error handling
// could also check for: todos.status === 'error'
if (todos.isError) {
return 'An error occurred'
}
return (
<div>
{todos.data.map((todo) => (
<Todo key={todo.id} {...todo} />
))}
</div>
)
}
Mutation에서의 콜백들은 그대로 유지됩니다.
v5부터는 데이터 패칭에 대한 suspense가 마침내 안정화되었습니다.
useQuery
에서 사용하던 suspense: boolean
옵션은 제거되고 useSuspenseQuery
, useSuspenseInfiniteQuery
, useSuspenseQueries
를 사용합니다.
const { data: post } = useSuspenseQuery({
// const post: Post
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
})
새로 추가된 suspense hook은 로딩과 에러 상태를 Suspense와 ErrorBoundary가 처리하기 때문에 status가 언제나 success인 data 값을 반환합니다. data가 undefined 상태가 되지 않습니다.
useMutation
의 variables를 활용하여 optimistic update를 간소화할 수 있습니다.
const queryInfo = useTodos()
const addTodoMutation = useMutation({
mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
if (queryInfo.data) {
return (
<ul>
{queryInfo.data.items.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
{addTodoMutation.isPending && (
<li key={String(addTodoMutation.submittedAt)} style={{ opacity: 0.5 }}>
{addTodoMutation.variables}
</li>
)}
</ul>
)
}
결과를 보여줄 곳이 한 곳에만 있으면 variables를 사용하는 방법이 간단하지만 다른 곳에서도 optimistic update에 대한 결과를 알아야 할 경우, 캐시를 다루어 그 동작을 할 수 있습니다.
useMutationState
로 MutationCache에 있는 mutation의 상태를 공유하고 다른 컴포넌트에서도 접근이 가능합니다. filter
옵션을 사용해 mutation을 필터링하고 select
옵션으로 상태 값을 가공하거나 선택할 수 있습니다.
// 모든 variables
const variables = useMutationState({
filter: { status: 'pending' },
select: (mutation) => mutation.state.variables,
})
// mutationKey로 mutation 식별
const mutationKey = ['posts']
const mutation = useMutation({
mutationKey,
mutationFn: (newPost) => {
return axios.post('/posts', newPost)
},
})
const data = useMutationState({
filters: { mutationKey },
select: (mutation) => mutation.state.data,
})
mutation을 고유한 키로 식별하거나 접근하고자 할 때 mutation.state.submittedAt
도 사용할 수 있습니다.
이전에는 undefined 값을 가진 pageParam
을 queryFn
에 전달했고, queryFn
에서 pageParam
에 대한 기본 값을 정의하였습니다. 하지만 이는 직렬화 할 수 없는 쿼리 캐시에 undefined로 저장된다는 단점이 있습니다.
따라서 Infinite query를 사용할 때 다음과 같이 명시적인 initialPageParam
을 전달해야 합니다.
useInfiniteQuery({
queryKey,
- queryFn: ({ pageParam = 0 }) => fetchSomething(pageParam),
+ queryFn: ({ pageParam }) => fetchSomething(pageParam),
+ initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.next,
})
많은 페이지를 가져올수록 더 많은 메모리를 사용하게 되며, 해당 쿼리에 대한 데이터를 추후에 요청할 때도 더 많은 시간이 소요되는데요, 이는 query refetching 프로세스를 느리게 합니다.
v5에서는 maxPages
옵션으로 무한 스크롤이 요청하는 최대 페이지에 제한을 설정할 수 있어 이런 단점을 보완할 수 있습니다. 참고로 infinite list는 양방향이어야 하기 때문에 getNextPageParam
과 getPreviousPageParam
을 모두 정의해야 한다는 점을 주의하여야 합니다.
useInfiniteQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
maxPages: 3,
})
Infinite queries의 경우에도 normal queries처럼 쿼리를 prefetch 할 수 있습니다.
기본적으로 한 개 페이지에 대한 쿼리를 prefetch 되며 지정된 queryKey
아래에 저장됩니다. 두 개 이상의 페이지를 미리 가져오려면 pages
옵션과 getNextPageParam
옵션으로 한 개 이상의 페이지를 prefetch 할 수 있습니다.
const prefetchTodos = async () => {
await queryClient.prefetchInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
pages: 3, // 세 개 페이지
})
}
Hydrate 컴포넌트는 HydrationBoundary로 변경되고 useHydrate
훅은 이제 사용되지 않습니다.
- import { Hydrate } from '@tanstack/react-query'
+ import { HydrationBoundary } from '@tanstack/react-query'
- <Hydrate state={dehydratedState}>
+ <HydrationBoundary state={dehydratedState}>
<App />
- </Hydrate>
+ </HydrationBoundary>
대부분의 개발자들이 cacheTime
을 오해하곤 합니다. 이는 마치 “데이터가 캐시되는 시간의 합계”처럼 들리는데, 이는 틀렸습니다. cacheTime
은 쿼리를 사용하는 컴포넌트가 언마운트 되면서 쿼리 인스턴스가 비활성화 됐을 때 부터 유효한 시간입니다. 따라서 데이터가 캐싱되어 있는 시간보다는 garbage collect 대상이 되기 까지의 시간이 더 적합한 설명입니다.
cacheTime
옵션이 gcTime으로 이름이 변경됩니다. 이는 조금 기술적인 용어이긴 하나, 컴퓨터 과학에서 잘 알려진 약어이기도 합니다.
status의 loading
은 pending
으로 변경됩니다.
isLoading
은 isPending
으로 변경됩니다.
isPending
&& isFetching
의 기능인 isInitialLoading
은 isLoading
으로 변경됩니다.
useErrorBoundary
옵션은 throwOnError
로 이름이 변경됩니다. react 훅의 접두사인 “use”와 특정 컴포넌트 명인 “ErrorBoundary”의 사용보다는 옵션이 제공하는 기능에 맞게 다음 렌더 사이클에 에러를 다시 던지는 throwOnError
로 변경됩니다.
keepPreviousData
옵션과 isPreviousData
가 제거되었습니다. placeholderData
옵션과 isPlaceholderData
와 거의 유사한 동작을 하기 때문인데요,
v5에서 keepPreviousData
는 react 쿼리에서 제공하는 함수(identity function)로 변경되어 모듈에 불러와 placeholderData
의 값으로 사용합니다.
import {
useQuery,
+ keepPreviousData
} from "@tanstack/react-query";
const {
data,
- isPreviousData,
+ isPlaceholderData,
} = useQuery({
queryKey,
queryFn,
- keepPreviousData: true,
+ placeholderData: keepPreviousData
});
또는, 직접 identity function을 제공하는 방법도 있습니다.
useQuery({
queryKey,
queryFn,
placeholderData: (previousData, previousQuery) => previousData,
});
identity function 이란 동일한 값의 매개변수를 반환 값으로 반환하는 함수입니다.
하지만 keepPreviousData
가 이전 query의 상태를 주었던 것과 다르게, placeholderData
는 언제나 success 상태를 줍니다. 이는 데이터를 성공적으로 가져온 후 백그라운드 refetch 오류가 발생한 경우 해당 상태는 오류일 수 있으나 그 자체는 공유되지 않았으므로 placeholderData
의 동작을 고수하기로 결정했습니다.
keepPreviousData
는 이전 데이터의 dataUpdatedAt
타임스탬프를 제공했지만 placeholderData
를 사용하면 dataUpdatedAt
이 0으로 유지됩니다. 해당 타임스탬프를 화면에 계속 표시하려는 경우 성가실 수 있으나 useEffect
를 사용하면 문제를 해결할 수 있습니다.
const [updatedAt, setUpdatedAt] = useState(0)
const { data, dataUpdatedAt } = useQuery({
queryKey: ['projects', page],
queryFn: () => fetchProjects(page),
})
useEffect(() => {
if (dataUpdatedAt > updatedAt) {
setUpdatedAt(dataUpdatedAt)
}
}, [dataUpdatedAt])
캐시에서 쿼리를 제거하는 remove
메소드는 이제 사용하지 않습니다. remove
메소드는 관찰자에게 알리지 않고 쿼리를 제거하는 기능을 했었는데요, 쿼리를 제거한 다음 렌더에는 새로운 로딩 상태로 이어지기 때문에 활성화 되어 있는 쿼리를 제거하는 것은 맞지 않다고 합니다.
쿼리를 제거해야 하는 경우 v5에서는 queryClient.removeQueries({ queryKey: key })
를 사용합니다.
const queryClient = useQueryClient();
const query = useQuery({ queryKey, queryFn });
- query.remove()
+ queryClient.removeQueries({ queryKey })
서버에서의 retry
기본 값은 3에서 0으로 변경됩니다.
prefetching의 경우 항상 기본값이 0이었지만, suspense가 활성화된 쿼리는 이제 서버에서도 직접 실행할 수 있기 때문에 서버에서 재시도를 전혀 하지 않도록 해야 합니다.
useQueries
의 combine으로 응답을 하나의 값으로 사용할 수 있습니다.
const ids = [1, 2, 3]
const combinedQuries = useQueries({
queries: ids.map(id => (
{ queryKey: ['post', id], queryFn: () => fetchPost(id) },
)),
combine: (results) => {
return ({
data: results.map(result => result.data),
pending: results.some(result => result.isPending),
})
}
})
다만 위의 경우 쿼리의 data와 pending 값만 반환되고 쿼리 결과의 다른 모든 속성은 손실된다는 점을 주의해야 됩니다.
함수 호출 시그니처를 객체 형식만으로 지원하고 에러의 타입을 정의하는 등 타입스크립트에서 많은 오버로드를 가지지 않도록 개선시키고 함수와 옵션을 간소화하는 등 많이 덜어낸 것 같습니다. 함수의 역할과 그 네이밍에 대해 많이 고민한 흔적도 보이네요, 동료 개발자님의 생각은 어떠하신가요? :)
TanStack Query v5
TanStack Query v5 정식 버전 살펴보기
ssi02014/react-query-tutorial