v5부터는 React 18과 TypeScript 4.7 이상의 버전에서만 사용이 가능합니다.

TanStack Query팀이 작년에 v5 로드맵을 발표하고 최근 v5 버전을 정식 출시했는데요, v4에 비해 번들 크기를 20% 줄이고 제공하는 API를 간소화하는데 중점을 두었다고 하네요. 최근 실험적으로 블로그에 변경 사항들을 적용해 봤는데요, 이번 글에서는 v5의 주요 변경 및 개선 사항들에 대해 살펴봅니다.


주요 변경 사항

하나의 객체로 쿼리 옵션 관리

v5에서 useQuery를 비롯한 함수들은 옵션들이 정의된 단일 객체를 전달받아 실행합니다.

diff
1- useQuery(key, fn, options)
2+ useQuery({ queryKey, queryFn, ...options })
3- useInfiniteQuery(key, fn, options)
4+ useInfiniteQuery({ queryKey, queryFn, ...options })
5- useMutation(fn, options)
6+ useMutation({ mutationFn, ...options })
7- queryClient.invalidateQueries(key, filters, options)
8+ queryClient.invalidateQueries({ queryKey, ...filters }, options)

이전 버전에서는 useQuery를 호출할 때 세 가지 방법이 있었는데요,

index.ts
1useQuery(queryKey, queryFn, options);
2useQuery(queryKey, options); // default query function 사용할 경우 query function 생략 가능
3useQuery(options);
4// v5 이전에는 queryKey만 필수 옵션

일관성이 떨어지는 점, 사용될 옵션을 생성할 때 첫 번째와 두 번째 매개변수의 타입이 무엇인지 확인하기 위해 런타임 체크가 필요한 점 등을 이유로 v5에서는 단일 객체를 전달받아 처리하는 방식으로 변경됐습니다.

v4에서는 useQuery.ts 파일의 143줄 중 3줄만 자바스크립트로 구성돼있다고 하니 타입 관련해서 유지 관리에 어려움이 있었다고 하네요.

더 자세한 내용은 Discussionremove overloads를 참고해 주세요.


useQuery에서 onSuccess, onError, onSettled 콜백(deprecated)

useQuery에서 onSuccess, onError, onSettled 콜백들은 이제 사용되지 않습니다.

콜백들을 제거한 가장 큰 이유들은 다음과 같은데요 :

  1. 예측 가능하고 일관성있는 useQuery
  2. 상태 동기화를 목적으로 사용했을 때 발생하는 추가 렌더 사이클. 예) onSuccess 콜백에 로컬 또는 전역 상태 업데이트 (참고)
  3. 콜백이 호출되지 않을 여지 예) staleTime 설정으로 query function이 호출되지 않아 의도한 콜백이 실행하지 않을 경우(참고)

v5가 정식으로 출시되고 나서부터는 콜백을 다음과 같은 방법으로 다룰 것을 제시합니다 :

  1. 전역 콜백으로 처리(참고)
  2. Error Boundary로 에러 처리(참고)
  3. status enum, isError 등으로 컴포넌트 내에서 처리(참고)

Mutation에서의 콜백들은 그대로 유지됩니다.

더 자세한 내용은 Discussion을 참고해 주세요.


suspense를 지원하는 useSuspenseQuery, useSuspenseInfiniteQuery, useSuspenseQueries

v5부터는 안정적으로 suspense를 사용해 데이터 패칭을 할 수 있습니다. useQuery에서 사용하던 suspense: boolean 옵션은 제거되고 useSuspenseQuery, useSuspenseInfiniteQueryuseSuspenseQueries를 사용합니다.

index.ts
1const { data: post } = useSuspenseQuery({
2  // const post: Post
3  queryKey: ['post', postId],
4  queryFn: () => fetchPost(postId),
5})

새로 추가된 suspense hook은 로딩과 에러 상태를 Suspense와 ErrorBoudnary가 처리하기 때문에 status가 언제나 success인 data 값을 반환합니다.

suspense와 관련된 더 자세한 내용은 이 문서를 참고해 주세요.


낙관적 업데이트 간소화

useMutation의 variables를 활용해서 낙관적 업데이트를 간소화할 수 있습니다.

index.ts
1const queryInfo = useTodos()
2const addTodoMutation = useMutation({
3  mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),
4  onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
5})
6
7if (queryInfo.data) {
8  return (
9    <ul>
10      {queryInfo.data.items.map((todo) => (
11        <li key={todo.id}>{todo.text}</li>
12      ))}
13      {addTodoMutation.isPending && (
14        <li key={String(addTodoMutation.submittedAt)} style={{ opacity: 0.5 }}>
15          {addTodoMutation.variables}
16        </li>
17      )}
18    </ul>
19  )
20}

낙관적 결과를 보여줄 곳이 한 곳에만 있으면 variables를 사용하는 방법이 간단하지만 다른 곳에서도 낙관적 업데이트에 대한 결과를 알아야하는 경우 캐시를 직접 다루는 방법이 적합합니다.

낙관적 업데이트와 관련된 더 자세한 내용은 이 문서를 참고해 주세요.


useMutationState로 mutation 상태 공유

useMutationState로 MutationCache에 있는 mutation의 상태를 공유하고 다른 컴포넌트에서도 접근이 가능합니다. filter옵션을 사용해 mutation을 필터링하고 select옵션으로 상태 값을 가공하거나 선택할 수 있습니다. useMutationState이 호출됐을 때 실행되고 있는 mutation이 한 개 이상일 수 있기 때문에 반환되는 값은 배열입니다.

index.ts
1// 모든 variables 
2const variables = useMutationState({
3  filters: { status: 'pending' },
4  select: (mutation) => mutation.state.variables,
5})
index.ts
1// mutationKey로 mutation 식별
2const mutationKey = ['posts']
3const mutation = useMutation({
4  mutationKey,
5  mutationFn: (newPost) => {
6    return axios.post('/posts', newPost)
7  },
8})
9const data = useMutationState({
10  filters: { mutationKey },
11  select: (mutation) => mutation.state.data,
12})

mutation을 고유한 키로 식별하거나 접근하고자 할 때 mutation.state.submittedAt도 사용할 수 있습니다.


Infinite Query에서 initialPageParam(required)

Infinite query를 사용할 때 pageParam의 초기 값으로 사용될 initialPageParam 옵션을 전달해야 합니다. 이전 버전에서는 queryFn의 pageParam이 undefined 값을 가져서 0 또는 초기 값을 정의했었는데 undefined는 직렬화되지 않아 initialPageParam 옵션이 추가됐습니다.

diff
1useInfiniteQuery({
2   queryKey,
3-  queryFn: ({ pageParam = 0 }) => fetchSomething(pageParam),
4+  queryFn: ({ pageParam }) => fetchSomething(pageParam),
5+  initialPageParam: 0,
6   getNextPageParam: (lastPage) => lastPage.next,
7})

Infinite Query에서 maxPages 옵션

maxPages 옵션으로 무한 스크롤이 요청하는 최대 페이지에 제한을 설정할 수 있습니다. 페이지를 요청할수록 쿼리 데이터가 축적되면 메모리를 더 많이 사용하고 해당 쿼리에 대한 데이터를 추후에 요청할 때도 더 많은 시간이 소요되는데요, maxPages 옵션으로 데이터에 대한 최대 페이지 제한을 둘 수 있습니다.


Infinite Query에서도 prefetch

Infinite query의 경우에도 쿼리를 prefetch 할 수 있습니다. 기본으로 한 개 페이지에 대한 쿼리를 prefetch 하지만 pages 옵션과 getNextPageParam옵션으로 한 개 이상의 페이지를 prefetch 할 수 있습니다.

index.ts
1const prefetchTodos = async () => {
2  await queryClient.prefetchInfiniteQuery({
3    queryKey: ['projects'],
4    queryFn: fetchProjects,
5    initialPageParam: 0,
6    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
7    pages: 3, // 세 개 페이지
8  })
9}

prefetch와 관련된 더 자세한 내용은 이 문서를 참고해 주세요.


Hydration API

Hydrate 컴포넌트는 HydrationBoundary로 변경되고 useHydrate 훅은 이제 사용되지 않습니다.

index.ts
1- import { Hydrate } from '@tanstack/react-query'
2+ import { HydrationBoundary } from '@tanstack/react-query'
3
4- <Hydrate state={dehydratedState}>
5+ <HydrationBoundary state={dehydratedState}>
6  <App />
7- </Hydrate>
8+ </HydrationBoundary>

cacheTime => gcTime

cacheTime 옵션은 gcTime으로 이름이 변경됩니다. "gc"는 garbage collecting의 약어로 기술적인 의미에서 gcTime으로 변경됩니다.

cacheTime은 쿼리를 사용하는 컴포넌트가 언마운트 되면서 쿼리 인스턴스가 비활성화됐을 때 부터 유효한 시간입니다. 따라서 데이터가 캐싱돼있는 시간보다는 가비지 컬렉팅 대상이 되기까지의 시간이 더 적합한 설명입니다.


status: loading => status: pending, isLoading => isPending, isInitialLoading => isLoading

status의 loadingpending으로 변경됩니다.

isLoadingisPending으로 변경됩니다.

isPending && isFetching의 기능인 isInitialLoadingisLoading으로 변경됩니다.

더 자세한 내용은 Discussionstatus: pending을 참고해 주세요.


useErrorBoundary => throwOnError

useErrorBoundary 옵션은 throwOnError로 이름이 변경됩니다. 리액트 훅의 접두사인 "use"와 특정 컴포넌트명인 "ErrorBoundary"의 사용보다는 옵션이 제공하는 기능에 맞게 다음 렌더 사이클에 에러를 다시 던지는 throwOnError로 변경됩니다.


keepPreviousData와 isPreviousData(deprecated)

keepPreviousData 옵션과 isPreviousData는 placeholderData 옵션과 isPlaceholderData로 변경됩니다. v5에서 keepPreviousData는 리액트 쿼리에서 제공하는 함수(identity function)로 변경되는데요, 모듈에 불러와 placeholderData의 값으로 사용합니다.

placeholderData: (previousData, previousQuery) => previousData,

diff
1import {
2   useQuery,
3+  keepPreviousData
4} from "@tanstack/react-query";
5
6const {
7   data,
8-  isPreviousData,
9+  isPlaceholderData,
10} = useQuery({
11  queryKey,
12  queryFn,
13- keepPreviousData: true,
14+ placeholderData: keepPreviousData
15});

리액트 쿼리에서 identity function이란 동일한 값의 매개변수를 반환 값으로 반환하는 함수입니다.


useQuery에서 remove 메소드(deprecated)

캐시에서 쿼리를 제거하는 remove 메소드는 이제 사용하지 않습니다. remove 메소드는 관찰자에게 알리지 않고 쿼리를 제거하는 기능을 했었는데요, 쿼리를 제거한 다음 렌더에는 새로운 로딩 상태로 이어지기 때문에 활성화돼있는 쿼리를 제거하는 것은 맞지 않다고 하네요.

쿼리를 제거해야하는 경우 v5에서는 queryClient.removeQueries({queryKey: key})를 사용합니다.

diff
1const queryClient = useQueryClient();
2 const query = useQuery({ queryKey, queryFn });
3- query.remove()
4+ queryClient.removeQueries({ queryKey })

서버에서 retry는 0

서버에서의 retry 기본 값은 3에서 0으로 변경됩니다.


combine

useQueries의 combine으로 응답(쿼리에 대한 정보 등)을 하나의 값으로 사용할 수 있습니다.

index.ts
1const ids = [1,2,3]
2const combinedQueries = useQueries({
3  queries: ids.map(id => (
4    { queryKey: ['post', id], queryFn: () => fetchPost(id) },
5  )),
6  combine: (results) => {
7    return ({
8      data: results.map(result => result.data),
9      pending: results.some(result => result.isPending),
10    })
11  }
12})

다만 위의 경우 쿼리의 data와 pending 값만 반환되고 쿼리에 대한 나머지 정보는 유실됩니다.


DevTools

새로운 UI, light mode, 인라인으로 캐시를 수정할 수 있는 기능 등을 제공합니다.


타입 관련 변경 사항

queryOptions으로 안전하게 타입 추론

useQuery에 인라인으로 쿼리 옵션들을 정의하지 않고 함수로 옵션들을 관리하는 경우 등에 queryOptions는 옵션 객체의 타입 추론을 도와줍니다.

index.ts
1import { queryOptions } from '@tanstack/react-query';
2
3function groupOptions() {
4  return queryOptions({
5    queryKey: ['groups'],
6    queryFn: fetchGroups,
7  });
8}
9useQuery(groupOptions());
10queryClient.prefetchQuery(groupOptions());

TError의 기본 타입 unknown => Error

useQuery의 두 번째 제네릭인 TError는 Error를 기본 타입으로 갖습니다.

이전 버전에서는 TError가 unknown 타입을 기본적으로 가졌는데요, 거의 모든 경우에 Error 타입을 갖기 때문에 v5부터 error 필드는 Error 타입으로 추론됩니다. 다만 의도적으로 Error 인스턴스가 아닌 커스텀 에러를 반환할 때는 따로 정의할 수 있습니다.

index.ts
1// v4
2const { error } = useQuery({ queryKey: ['groups'], queryFn: fetchGroups });
3// const error: unknown
4const { error } = useQuery<Group[], Error>(['groups'], fetchGroups);
5// const error: Error | null
index.ts
1// v5
2const { error } = useQuery({ queryKey: ['groups'], queryFn: fetchGroups });
3// const error: Error | null
4
5// 커스텀 에러
6const { error } = useQuery<Group[], string>(['groups'], fetchGroups)
7// const error: string | null

Register로 전역 에러 타입 설정

useQuery마다 에러 타입을 제네릭으로 알려주지 않고 Register 인터페이스로 기본 에러 타입을 정의할 수 있습니다.

index.d.ts
1declare module '@tanstack/react-query' {
2  interface Register {
3    defaultError: AxiosError;
4  }
5}
index.ts
1const { error } = useQuery({ queryKey: ['groups'], queryFn: fetchGroups });
2// const error: AxiosError | null

앞서 TError의 기본 타입을 Error로 정의한 것도 Register 인터페이스를 사용한 것으로 보이네요.

useQuery.d.ts
1declare function useQuery<TQueryFnData = unknown, TError = DefaultError, ..>(options: .. ,)
queryClient.d.ts
1interface Register {
2}
3type DefaultError = Register extends {
4    defaultError: infer TError;
5} ? TError : Error;

호환 버전

리액트 버전 호환

v5에서는 useSyncExternalStore를 사용하고 있어서 React 18 또는 이후 버전에서만 사용이 가능합니다.


타입스크립트 버전 호환

TypeScript 4.7 또는 이후 버전에서만 사용이 가능합니다.


지원 브라우저

리액트 쿼리는 최신 브라우저에 최적화되어 있습니다.

bash
1Chrome >= 91
2Firefox >= 90
3Edge >= 91
4Safari >= 15
5iOS >= 15
6opera >= 77

출처