눈에 띄는 변화들만 정리
1. 하나의 함수 호출 시그니처(객체 형식)만을 지원
// useQuery
- useQuery(key, fn, options)
+ useQuery({ queryKey, queryFn, ...options })
- useInfiniteQuery(key, fn, options)
+ useInfiniteQuery({ queryKey, queryFn, ...options })
- useMutation(fn, options)
+ useMutation({ mutationFn, ...options })
- useIsFetching(key, filters)
+ useIsFetching({ queryKey, ...filters })
- useIsMutating(key, filters)
+ useIsMutating({ mutationKey, ...filters })
// queryClient
- queryClient.isFetching(key, filters)
+ queryClient.isFetching({ queryKey, ...filters })
- queryClient.ensureQueryData(key, filters)
+ queryClient.ensureQueryData({ queryKey, ...filters })
- queryClient.getQueriesData(key, filters)
+ queryClient.getQueriesData({ queryKey, ...filters })
- queryClient.setQueriesData(key, updater, filters, options)
+ queryClient.setQueriesData({ queryKey, ...filters }, updater, options)
- queryClient.removeQueries(key, filters)
+ queryClient.removeQueries({ queryKey, ...filters })
- queryClient.resetQueries(key, filters, options)
+ queryClient.resetQueries({ queryKey, ...filters }, options)
- queryClient.cancelQueries(key, filters, options)
+ queryClient.cancelQueries({ queryKey, ...filters }, options)
- queryClient.invalidateQueries(key, filters, options)
+ queryClient.invalidateQueries({ queryKey, ...filters }, options)
- queryClient.refetchQueries(key, filters, options)
+ queryClient.refetchQueries({ queryKey, ...filters }, options)
- queryClient.fetchQuery(key, fn, options)
+ queryClient.fetchQuery({ queryKey, queryFn, ...options })
- queryClient.prefetchQuery(key, fn, options)
+ queryClient.prefetchQuery({ queryKey, queryFn, ...options })
- queryClient.fetchInfiniteQuery(key, fn, options)
+ queryClient.fetchInfiniteQuery({ queryKey, queryFn, ...options })
- queryClient.prefetchInfiniteQuery(key, fn, options)
+ queryClient.prefetchInfiniteQuery({ queryKey, queryFn, ...options })
// queryCahce
- queryCache.find(key, filters)
+ queryCache.find({ queryKey, ...filters })
- queryCache.findAll(key, filters)
+ queryCache.findAll({ queryKey, ...filters })
(다행히 나는 원래부터 객체 형식으로만 적었기 때문에 딱히 상관없음.)
2. queryClient.getQueryData 와 queryClient.getQueryState 는 인자로 queryKey만 받도록 변경됨
- queryClient.getQueryData(queryKey, filters)
+ queryClient.getQueryData(queryKey)
- queryClient.getQueryState(queryKey, filters)
+ queryClient.getQueryState(queryKey)
3. useQuery에서 remove 메서드가 제거됨
v5부터 query를 제거하려면, queryClient.removeQueries({queryKey: key}) 를 사용하세요.
const queryClient = useQueryClient();
const query = useQuery({ queryKey, queryFn });
- query.remove()
+ queryClient.removeQueries({ queryKey })
(나는 원래부터 removeQueries로 해왔기 때문에 타격없음.)
4. cacheTime 을 gcTime 으로 이름 변경 🌟
대부분의 개발자들이 cacheTime 을 오해하고 있습니다. cacheTime은 마치 “데이터가 캐시되는 시간의 합계”처럼 들리는데, 이는 틀린 말입니다.
tanstackQuery의 cacheTime 은 query가 여전히 사용되고 있는 경우에는 시간을 재지 않다가, query가 사용되지 않는 즉시 시간을 재기 시작합니다. 해당 시간이 다 지나면, 해당 데이터는 garbage collect 됩니다.
gc 는 “garbage collect” 시간을 의미합니다. 이는 조금 기술적인 용어이긴 하나, 컴퓨터 과학에서 잘 알려진 약어이기도 합니다.
const MINUTE = 1000 * 60;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
- cacheTime: 10 * MINUTE,
+ gcTime: 10 * MINUTE,
},
},
})
5. useErrorBoundary 옵션 이름이 throwOnError 로 변경됨 🌟
특정 프레임워크에 종속되지 않으면서 React의 훅의 접미사인 “use”와 “ErrorBoundary” 컴포넌트명과 혼동을 피하기 위해, useErrorBoundary 옵션의 이름을 throwOnError 로 변경하였습니다. 변경된 이름이 기능을 보다 정확하게 반영합니다.
6. 타입스크립트: 에러의 기본 타입이 unknown 에서 Error 로 변경됨
에러의 기본 타입은 Error 입니다. 왜냐하면 이것이 대부분의 사용자가 기대하는 결과이기 때문입니다.
const { error } = useQuery({ queryKey: ['groups'], queryFn: fetchGroups })
// ^? const error: Error
const { error } = useQuery<Group[], string>(['groups'], fetchGroups)
// ^? const error: string | null
7. keepPreviousData, isPreviousData 를 제거함 🌟
keepPreviousData 옵션과 isPreviousData 플래그를 제거하였습니다. 왜냐하면 이들은 placeholderData 와 isPlaceholderData 플래그와 거의 유사한 동작을 하기 때문입니다.
placeholderData: (previousData, previousQuery) => previousData,
import {
useQuery,
+ keepPreviousData
} from "@tanstack/react-query";
const {
data,
- isPreviousData,
+ isPlaceholderData,
} = useQuery({
queryKey,
queryFn,
- keepPreviousData: true,
+ placeholderData: keepPreviousData
});
하지만 이 변경 사항에는 몇 가지 주의 사항이 있습니다.
keepPreviousData가 이전 query의 상태를 주었던 것과 다르게,placeholderData는 언제나success상태를 줍니다. 이는 데이터를 성공적으로 가져온 후 백그라운드 refetch에러가 발생한 경우, 이러한 success 상태는 잘못된 것으로 볼 수 있습니다. 그러나 에러 자체가 공유되지는 않았으므로,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])
- [ ]
8. ReactQueryDevtools
position 세분화
position은 판넬의 위치 buttonPosition은 판넬을 열고 닫는 🌸꽃의 위치
- <ReactQueryDevtools initialIsOpen={false} position='bottom-right' />
+ <ReactQueryDevtools initialIsOpen={false} position='bottom' buttonPosition='bottom-right' />
9. 커스텀 context prop을 제거함
커스텀 queryClient 인스턴스를 위해 커스텀 context prop을 제거하였습니다.
v4에서 커스텀 context 를 모든 리액트 쿼리 훅에 전달할 수 있는 방법을 제공하였습니다. 이는 마이크로프론트엔드 사용 시 적절한 격리를 가능하게 했습니다.
하지만, context 는 리액트에서만 사용 가능한 기능입니다. context는 queryClient에 접근권을 주는 역할을 할뿐입니다. 이와 동일한 기능을 커스텀 queryClient 를 직접 전달 가능하게 함으로써 해결했습니다. 이를 통해 특정 프레임워크에 구애받지 않으면서 동일한 기능을 가능하게 했습니다.
import { queryClient } from './my-client'
const { data } = useQuery(
{
queryKey: ['users', id],
queryFn: () => fetch(...),
- context: customContext
},
+ queryClient,
)
custom queryClient(컴포넌트 경계마다 다른 queryClient를 주입할 수 있음)는 기존에도 있던 기능임. 아주 예전에 한 번 보긴 했었는데, 사용할 일이 없었음. 나중에 queryClient config를 컴포넌트 경계마다 다르게 나눠서 주입할 일 있으면 사용해 봐야겠음. 현재는 meta프롭으로 에러만 전역으로 관리하고 있다.
10. refetchPage 를 제거함. 대신 maxPages를 추가함. ⭐
maxPages 를 위해 refetchPage 를 제거했습니다.
refetchPage 가 모든 페이지를 refetch하는 방법은 UI 불일치로 이어질 수 있습니다.
v5는 Infinite Queries가 query 데이터에 저장하고 refetch 할 페이지 수를 제한할 수 있는 방법으로 maxPages 옵션을 제공합니다.
maxPages: number | undefined- The maximum number of pages to store in the infinite query data.
infinite query data에 저장할 최대 페이지 수 - When the maximum number of pages is reached, fetching a new page will result in the removal of either the first or last page from the pages array, depending on the specified direction.
지정한 maxPages에 도달했을 때, 새로운 페이지를 fetch하는 것은 지정한 설정에 의존하며 첫 페이지 혹은 마지막 페이지의 제거를 유발할 수 있습니다. - If
undefinedor equals0, the number of pages is unlimited
undefined혹은0과 값이 같다면, 저장할 최대 페이지 수는 무한대입니다. - Default value is
undefined
기본 값은undefined입니다. getNextPageParamandgetPreviousPageParammust be properly defined ifmaxPagesvalue is greater than0to allow fetching a page in both directions when needed.
만약maxPages값이0보다 클 경우 양방향(이전 페이지 혹은 이후 페이지)으로 페이지를 fetch하는 것을 허용하기 위해서 필요에 따라getNextPageParam과getPreviousPageParam을 적절하게 정의해두어야 합니다.
- The maximum number of pages to store in the infinite query data.
어떤 식으로 동작한다는 것인지?
예를 들어 maxPages가 3이고 이미 쿼리된 데이터의 총 페이지 수가 3이라면, nextPage로 하나를 불러올 때마다 1페이지는 쿼리상에서 삭제됨. 이해가 안되면 여기 🔗링크를 통해 눈으로 확인해봅시다. 바로 이해가 됨.
11. Infinite Query는 이제 initialPageParam 이 필요함 🌟
이전에는 undefined를 queryFn에 pageParam으로 전달할 수 있었습니다. 또는 queryFn 함수 호출 시그니처에서 pageParam 매개변수를 기본값으로 할당할 수도 있었습니다. 이는 직렬화될 수 없는 undefined를 queryCache에 저장한다는 단점이 있었습니다.
대신에 이제 Infinite Query 옵션에 명시적인 initialPageParam 을 전달합니다.
useInfiniteQuery({
queryKey,
- queryFn: ({ pageParam = 0 }) => fetchSomething(pageParam),
+ queryFn: ({ pageParam }) => fetchSomething(pageParam),
+ initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.next,
})
12. useQuery에서 isDataEqual 옵션이 제거됨
isDataEqual 함수는 query에서 resovle된 데이터로서 이전 데이터를 사용할지 아니면 새 데이터를 사용할지 확인하는 데 사용됐습니다.
이제는 isDataEqual 를 사용하지 않고, 동일한 기능을 structuralSharing 에 함수를 넘김으로써 대신합니다.
import { replaceEqualDeep } from '@tanstack/react-query'
- isDataEqual: (oldData, newData) => customCheck(oldData, newData)
+ structuralSharing: (oldData, newData) => customCheck(oldData, newData) ? oldData : replaceEqualDeep(oldData, newData)
🔗Structural Sharing 기능이 궁금하다면?
(tkdodo 글은 가독성이 좋아서 읽기 좋음)
JSON 타입의 데이터에 대해서 replaceEqualDeep을 수행함.
select 프로퍼티로 받은 JSON 타입의 데이터 중 특정 인덱스만 추적하고 있다면, 해당 데이터가 속한 전체 배열의 업데이트 여부와 상관없이 그 특정 인덱스가 변했을 때만 렌더링 할 수 있도록 해줌.
13. loading 상태의 이름이 pending으로 변경되었습니다.🌟
status: loading →status: pending ,
isLoading → isPending,
isInitialLoading → isLoading
14. useQuery에서 onSuccess, onError, onSettled 콜백 deprecated ⭐
useQuery에서 onSuccess, onError, onSettled 콜백들은 이제 사용되지 않습니다.
tanstack query 버전 관리자 tkdodo는 useQuery의 on*에서 상태 동기화를 목적으로 사용했을 때 발생할 수 있는 추가 렌더 사이클 문제를 꼬집었습니다. (- 렌더 사이클 중간에 동기화되지 않은 값을 가진다. 불필요하게 2번 렌더링한다.)
예) onSuccess 콜백에 로컬 또는 전역 상태 업데이트. 콜백 안의 dispatcher(setState)가 스케쥴링 되기 전에 이미 값을 가져오는 것은 성공한 상황. 이 때 만약 dispatcher스케쥴링 전에 아직 바뀌지 않은 state를 참조하는 (onSuccess에서 혹은 밖에서 )로직을 짠다면 문제가 벌어진다. -> 실수로 동기화 되지 않은 state를 참조할 수 있어 버그 발생 위험 크다.
아래 두 코드를 비교해보면 이해할 수 있을 것입니다.
개선 전 코드
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 }
}
개선 후 코드
// derive-state
export function useTodos() {
const { data: todos } = useQuery({
queryKey: ['todos', 'list'],
queryFn: fetchTodos,
})
const todoCount = todos?.length ?? 0
return { todos, todoCount }
}
v5가 정식으로 출시되고 나서부터는 콜백을 다음과 같은 방법으로 다룰 것을 제시합니다 :
Mutation에서의 콜백들은 그대로 유지됩니다.
15. suspense를 지원하는 useSuspenseQuery, useSuspenseInfiniteQuery, useSuspenseQueries ⭐
v5부터는 안정적으로 suspense를 사용해 데이터 패칭을 할 수 있습니다. useQuery에서 사용하던 suspense: boolean 옵션은 제거되고 useSuspenseQuery, useSuspenseInfiniteQuery와 useSuspenseQueries를 사용합니다.
const { data: post } = useSuspenseQuery({
// const post: Post
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
})
새로 추가된 suspense hook은 로딩과 에러 상태를 Suspense와 ErrorBoudnary가 처리하기 때문에 status가 언제나 success인 data 값을 반환합니다.
16. 서버에서 retry는 0
17. Hydration API ⭐
Hydrate 컴포넌트는 HydrationBoundary로 변경되고 useHydrate 훅은 이제 사용되지 않습니다.
- import { Hydrate } from '@tanstack/react-query'
+ import { HydrationBoundary } from '@tanstack/react-query'
- <Hydrate state={dehydratedState}>
+ <HydrationBoundary state={dehydratedState}>
<App />
- </Hydrate>
+ </HydrationBoundary>
새로운 기능
1. simplified optimistic update(= via the UI)🌟
기존의 Optimistic update하는 방법(Via the cache)에 한 가지 방법(Via the UI)이 더 추가되었습니다.
optimistic update를 수행할 수 있는 간단한 새로운 방법으로서 useMutation에서 반환된 variables 를 이용합니다.
variables의 타입은 useMutation 제네릭의 세 번째 인자 TVariables에 할당되는 타입임. 아래 사용한 코드에서는 string타입을 보내는데 자동으로 TVariables타입에 할당됨. 쭉 거슬러 올라가보면 MutationState에 도착하는데 variables가 TVariables 또는 undefined 타입임을 알 수 있음. 실제로 컴포넌트에서 사용하는 variables의 타입이 string|undefined 타입이었는데 그 이유를 여기서 찾을 수 있었음.
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>
)
}
interface MutationState<TData = unknown, TError = DefaultError, TVariables = void, TContext = unknown> {
context: TContext | undefined;
data: TData | undefined;
error: TError | null;
failureCount: number;
failureReason: TError | null;
isPaused: boolean;
status: MutationStatus;
variables: TVariables | undefined; // 이 부분을 얘기하는 것이다.
submittedAt: number;
}
If the mutation and the query don’t live in the same component
(optimistic update via the ui 방법 쓰는데
서로 다른 컴포넌트에 mutation과 query가 각각 위치해 있을 경우 사용법.)
This approach works very well if the mutation and the query live in the same component, However, you also get access to all mutations in other components via the dedicated useMutationState hook. It is best combined with a mutationKey:
tsx
// somewhere in your app
const { mutate } = useMutation({
mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
mutationKey: ['addTodo']
})
// access variables somewhere else
const variables = useMutationState<string>({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (mutation) => mutation.state.variables
})
variables will be an Array, because there might be multiple mutations running at the same time. If we need a unique key for the items, we can also select mutation.state.submittedAt. This will even make displaying concurrent optimistic updates a breeze.
2. useMutationState로 mutation 상태 공유
useMutationState로 MutationCache에 있는 mutation의 상태를 공유하고 다른 컴포넌트에서도 접근이 가능합니다. filter옵션을 사용해 mutation을 필터링하고 select옵션으로 상태 값을 가공하거나 선택할 수 있습니다. useMutationState이 호출됐을 때 실행되고 있는 mutation이 한 개 이상일 수 있기 때문에 반환되는 값은 배열입니다.
// 모든 variables
const variables = useMutationState({
filters: { 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도 사용할 수 있습니다.
3. CreateStore
쿼리가 내부적으로 저장되는 방법을 커스터마이징하는 방법을 만들었습니다. 기본적으로 Map 자료구조가 사용되지만, 새로운 createStore 함수를 통하여 원하는 어떤 자료구조든 사용할 수 있습니다.
const queryClient = new QueryClient({
queryCache: new QueryCache({
createStore: () => new Map()
}),
})
4. combine ⭐
useQueries의 combine으로 응답(쿼리에 대한 정보 등)을 하나의 값으로 변경되어 return되도록 (사용)할 수 있습니다.
const ids = [1,2,3]
const combinedQueries = 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 값만 반환되고 쿼리에 대한 나머지 정보는 유실됩니다.
5. Infinite Query에서도 prefetch 가능 ⭐
pages가 추가됨.
const prefetchTodos = async () => {
// The results of this query will be cached like a normal query
await queryClient.prefetchInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
+ pages: 3, // prefetch the first 3 pages
})
}
이제 Infinite query의 경우에도 일반 Queries처럼 쿼리를 prefetch 할 수 있습니다. 기본으로 한 개 페이지에 대한 쿼리를 prefetch 하지만 pages 옵션과 getNextPageParam옵션으로 한 개 이상의 페이지를 prefetch 할 수 있습니다.
const prefetchTodos = async () => {
// 일반 쿼리처럼 이 쿼리의 결과는 캐싱된다.
await queryClient.prefetchInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
pages: 3, // 세 개 페이지
})
}
🔗Prefetching & Router Integration
참고링크
이 글은 옵시디언(Obsidian)에서 작성되었습니다. 티스토리에서 상대경로 링크는 작동하지 않습니다. 큰 이미지 파일은 업로드되지 않을 수 있습니다.