TanStack Query V5 톺아보기

limhi·2024년 1월 31일
0

React Query

목록 보기
2/4
post-thumbnail

TanStack Query가 최근 10월 중 v5 버전을 정식 출시하였는데요, v4에 비해 번들 크기를 20% 줄이고 제공하는 API를 간소화하는데 중점을 두었다고 합니다. 따라서 v5의 주요 변경 및 개선 사항들에 대해 살펴보겠습니다.

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

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만 필수 옵션이고 다양한 형태로 작성할 수 있었기에 일관성이 떨어지는 점, 사용될 옵션을 생성할 때 첫 번째와 두 번째 매개변수의 타입이 무엇인지 확인하기 위해 런타임 체크가 필요한 점 등을 이유로 이와 같이 변경되었습니다.

2. onSuccess, onError, onSettled

v4까지 있던 onSuccess, onError, onSettled Callback은 useQuery 옵션에서 이제 사용되지 않습니다.

콜백을 제거한 이유는 다음과 같습니다:

  1. 예측 가능하고 일관성 있는 useQuery
  2. 상태 동기화를 목적으로 사용했을 때 발생하는 추가 렌더 사이클. ex) 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 }
}
  1. 콜백이 호출되지 않을 여지. ex) staleTime 설정으로 query function이 호출되지 않아 의도한 콜백이 실행되지 않을 경우
export function useTodos() {
  const { dispatch } = useDispatch()

  return useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
    onSuccess: (data) => {
      dispatch(setTodos(data))
    },
  })
}

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

  1. 전역 콜백으로 처리
const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) =>
      toast.error(`Something went wrong: ${error.message}`),
  }),
})
  1. Error Boundary로 에러 처리
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...'
}
  1. status enum, 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에서의 콜백들은 그대로 유지됩니다.

3. 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와 ErrorBoundary가 처리하기 때문에 status가 언제나 success인 data 값을 반환합니다. data가 undefined 상태가 되지 않습니다.

4. useMutation

optimistic updates 간소화

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로 mutation 상태 공유

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도 사용할 수 있습니다.

5. Infinite Query

intialPageParam(required)

이전에는 undefined 값을 가진 pageParamqueryFn에 전달했고, queryFn에서 pageParam에 대한 기본 값을 정의하였습니다. 하지만 이는 직렬화 할 수 없는 쿼리 캐시에 undefined로 저장된다는 단점이 있습니다.

따라서 Infinite query를 사용할 때 다음과 같이 명시적인 initialPageParam을 전달해야 합니다.

useInfiniteQuery({
   queryKey,
-  queryFn: ({ pageParam = 0 }) => fetchSomething(pageParam),
+  queryFn: ({ pageParam }) => fetchSomething(pageParam),
+  initialPageParam: 0,
   getNextPageParam: (lastPage) => lastPage.next,
})

maxPages 옵션

많은 페이지를 가져올수록 더 많은 메모리를 사용하게 되며, 해당 쿼리에 대한 데이터를 추후에 요청할 때도 더 많은 시간이 소요되는데요, 이는 query refetching 프로세스를 느리게 합니다.

v5에서는 maxPages 옵션으로 무한 스크롤이 요청하는 최대 페이지에 제한을 설정할 수 있어 이런 단점을 보완할 수 있습니다. 참고로 infinite list는 양방향이어야 하기 때문에 getNextPageParamgetPreviousPageParam을 모두 정의해야 한다는 점을 주의하여야 합니다.

useInfiniteQuery({
  queryKey: ["todos"],
  queryFn: fetchTodos,
  initialPageParam: 0,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
  maxPages: 3,
})

prefetch

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, // 세 개 페이지
  })
}

6. 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>

7. cacheTime => gcTime 으로 용어 변경

대부분의 개발자들이 cacheTime을 오해하곤 합니다. 이는 마치 “데이터가 캐시되는 시간의 합계”처럼 들리는데, 이는 틀렸습니다. cacheTime은 쿼리를 사용하는 컴포넌트가 언마운트 되면서 쿼리 인스턴스가 비활성화 됐을 때 부터 유효한 시간입니다. 따라서 데이터가 캐싱되어 있는 시간보다는 garbage collect 대상이 되기 까지의 시간이 더 적합한 설명입니다.

cacheTime 옵션이 gcTime으로 이름이 변경됩니다. 이는 조금 기술적인 용어이긴 하나, 컴퓨터 과학에서 잘 알려진 약어이기도 합니다.

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

status의 loadingpending으로 변경됩니다.

isLoadingisPending으로 변경됩니다.

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

9. useErrorBoundary => throwOnError

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

10. keepPreviousData와 isPreviousData(deprecated)

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])

11. useQuery에서 remove 메소드(deprecated)

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

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

const queryClient = useQueryClient();
 const query = useQuery({ queryKey, queryFn });
- query.remove()
+ queryClient.removeQueries({ queryKey })

12. retry

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

prefetching의 경우 항상 기본값이 0이었지만, suspense가 활성화된 쿼리는 이제 서버에서도 직접 실행할 수 있기 때문에 서버에서 재시도를 전혀 하지 않도록 해야 합니다.

13. combine

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 값만 반환되고 쿼리 결과의 다른 모든 속성은 손실된다는 점을 주의해야 됩니다.


함수 호출 시그니처를 객체 형식만으로 지원하고 에러의 타입을 정의하는 등 타입스크립트에서 많은 오버로드를 가지지 않도록 개선시키고 함수와 옵션을 간소화하는 등 많이 덜어낸 것 같습니다. 함수의 역할과 그 네이밍에 대해 많이 고민한 흔적도 보이네요, 동료 개발자님의 생각은 어떠하신가요? :)

references

TanStack Query v5
TanStack Query v5 정식 버전 살펴보기
ssi02014/react-query-tutorial

profile
null 사랑하지 않아 - 어반자카파

0개의 댓글