2023년 10월, TanStack Query v5가 출시되면서 API의 내부 아키텍처가 크게 개선되었다.
그 중 주목할 만한 변화가 있었다.
useQuery
에서onSuccess
,onError
,onSettled
와 같은 콜백 함수들이 제거되었다.
이 변화는 처음에는 많은 개발자에게 혼란을 줄 수 있지만,
TanStack Query 팀의 의도는 기존 콜백의 문제점 해결과 더 단순하고 일관된 API 설계를 추구하는 데 있다.
이번 글에서는 이러한 변화와 그에 따른 대처 방법에 대해 알아본다.
useQuery
특징과 onSuccess
와 onError
역할TanStack Query v4에서는 useQuery
가 다양한 형태로 호출될 수 있었다.
One of the "cute and dynamic" constructs we had in React Query from when it started out (where it had no types), was was actually
useQuery
, because you could call it 3 different ways:with different positional arguments. There's no good way to make this work in TypeScript except with overloads, which is what we did. Overloads are problematic because they are a lot of overhead and error messages aren't good.
“React Query 초기에는 타입이 없었던 시절부터
useQuery
는 '귀엽고 동적인' 구조 중 하나였습니다.useQuery
는 세 가지 다른 방식으로 호출할 수 있었습니다. 각기 다른 위치에 인자를 전달할 수 있었죠. TypeScript에서 이를 제대로 작동하게 만드는 방법은 오버로딩 외에는 없습니다. 그래서 우리는 오버로딩을 사용했습니다. 그러나 오버로딩은 많은 오버헤드를 유발하며 오류 메시지가 그리 좋지 않다는 문제가 있습니다.”— TkDodo, "React Query API Design Lessons Learned"
useQuery
는 다음과 같은 다양한 형태의 호출을 지원했다:
queryKey
와 옵션을 함께 전달할 때queryKey
, queryFn
, 옵션을 모두 전달할 때이러한 다양한 호출 방식을 처리하기 위해 TanStack Query v4의 useQuery
는 여러 오버로딩 시그니처를 정의했다.
// query/packages/react-query/src/useQuery.ts
'use client'
import { QueryObserver, parseQueryArgs } from '@tanstack/query-core'
import { useBaseQuery } from './useBaseQuery'
import type { QueryFunction, QueryKey } from '@tanstack/query-core'
import type {
DefinedUseQueryResult,
UseQueryOptions,
UseQueryResult,
} from './types'
// HOOK
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
options: Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'initialData'
> & { initialData?: () => undefined },
): UseQueryResult<TData, TError>
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
options: Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'initialData'
> & { initialData: TQueryFnData | (() => TQueryFnData) },
): DefinedUseQueryResult<TData, TError>
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
): UseQueryResult<TData, TError>
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
queryKey: TQueryKey,
options?: Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey' | 'initialData'
> & { initialData?: () => undefined },
): UseQueryResult<TData, TError>
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
queryKey: TQueryKey,
options?: Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey' | 'initialData'
> & { initialData: TQueryFnData | (() => TQueryFnData) },
): DefinedUseQueryResult<TData, TError>
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
queryKey: TQueryKey,
options?: Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey'
>,
): UseQueryResult<TData, TError>
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
queryKey: TQueryKey,
queryFn: QueryFunction<TQueryFnData, TQueryKey>,
options?: Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey' | 'queryFn' | 'initialData'
> & { initialData?: () => undefined },
): UseQueryResult<TData, TError>
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
queryKey: TQueryKey,
queryFn: QueryFunction<TQueryFnData, TQueryKey>,
options?: Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey' | 'queryFn' | 'initialData'
> & { initialData: TQueryFnData | (() => TQueryFnData) },
): DefinedUseQueryResult<TData, TError>
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
queryKey: TQueryKey,
queryFn: QueryFunction<TQueryFnData, TQueryKey>,
options?: Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey' | 'queryFn'
>,
): UseQueryResult<TData, TError>
export function useQuery<
TQueryFnData,
TError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
arg1: TQueryKey | UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
arg2?:
| QueryFunction<TQueryFnData, TQueryKey>
| UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
arg3?: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
): UseQueryResult<TData, TError> {
const parsedOptions = parseQueryArgs(arg1, arg2, arg3)
return useBaseQuery(parsedOptions, QueryObserver)
}
그리고 useQuery
훅의 옵션 중 onSuccess
와 onError
콜백을 사용하여,
쿼리가 성공하거나 실패한 후에 부수 효과를 처리할 수 있었다.
import { useQuery } from '@tanstack/react-query';
const TodoList = () => {
const { data, error } = useQuery(['todos'], fetchTodos, {
onSuccess: (data) => {
console.log('할 일 목록을 가져왔습니다:', data);
},
onError: (error) => {
console.error('할 일 목록을 가져오는 중 오류 발생:', error);
},
});
// 렌더링 로직...
}
TanStack Query v5에서는 useQuery
의 오버로드가 단일 객체 시그니처로 변경되고, onSuccess
, onError
, onSettled
와 같은 콜백 함수들이 제거됐다. 이유는 다음과 같다.
queryKey
를 사용하는 여러 컴포넌트에서의 중복 호출 문제:queryKey
를 사용하는 여러 컴포넌트가 존재할 때, onSuccess
콜백이 각 컴포넌트마다 호출되는 문제가 발생할 수 있었다.// useTodos.ts
export const useTodos = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
onSuccess: () => {
console.log('onSuccess from useTodos');
}
});
// ComponentOne.tsx
const ComponentOne = () => {
const { data } = useTodos();
}
// ComponentTwo.tsx
const ComponentTwo = () => {
const { data } = useTodos();
}
TkDodo가 든 위 예시에서는
useTodos
훅의 queryKey
가 동일하기 때문에, onSuccess
콜백이 ComponentOne
과 ComponentTwo
에서 각각 호출된다.
따라서 console.log
문이 두 번 실행되며, 만약 토스트 알림 코드가 있다면 두 번 표시될 것이다.
useQuery
의 콜백은 처음에는 이러한 기준을 충족하는 것처럼 보였지만, 실제로는 복잡성과 버그를 초래할 수 있다.APIs need to be simple, intuitive and consistent. The callbacks on useQuery
look like they fit these criteria, but they are bug-producers in disguise. It's pretty bad because they will likely do what you want when you first implement them, but they have a toll when you refactor or extend your App as it grows. They also invite antipatterns because you can introduce error-prone state-syncing without feeling bad while doing so.
"API는 단순하고 직관적이며 일관성 있어야 합니다. useQuery의 콜백은 이러한 기준을 충족하는 것처럼 보이지만, 사실 버그를 유발하는 요인입니다. 처음 구현할 때는 원하는 대로 동작할 가능성이 높지만, 앱이 커지고 리팩터링하거나 확장할 때 그 대가를 치르게 됩니다. 또한 오류가 발생하기 쉬운 상태 동기화 패턴을 무의식적으로 도입할 수 있어, 문제를 유발할 수 있습니다."
— TkDodo, "Breaking React Query's API on purpose"
이러한 이유로 TanStack Query v5에서는 useQuery
콜백들이 제거되며, 대신 React의 권장 패턴인 useEffect
훅을 사용하여 부수 효과를 처리하도록 유도하고 있다.
useEffect
와 queryClient
활용TkDodo는 부수 효과를 처리하기 위해 useEffect
와 전역의 queryClient
를 활용할 것을 권장한다.
useEffect
를 사용한 부수 효과 처리첫 번째 방법은 useEffect
를 사용하여 부수 효과를 처리하는 것이다.
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
function TodoList() {
const { data, error, isSuccess, isError } = useQuery(['todos'], fetchTodos);
useEffect(() => {
if (isSuccess) {
console.log('할 일 목록을 가져왔습니다:', data);
}
}, [isSuccess, data]);
useEffect(() => {
if (isError) {
console.error('할 일 목록을 가져오는 중 오류 발생:', error);
}
}, [isError, error]);
// 렌더링 로직...
}
..물론,
TanStack Query v4의 주요 장점 중 하나인 useEffect
없이 API 호출 및 상태 관리를 할 수 있다는 점을 좋아했던 개발자분들로부터 미움을 받는 거 같다. 😂
(TkDodo의 useEffect 방식 제안에 대해 굿바이 인사를 하는 개발자.. 🥲)
두 번째 방법은 Query
의 meta
필드를 사용하는 것이다.
meta
는 원하는 정보를 자유롭게 담을 수 있는 객체로, 글로벌 콜백 등 쿼리에 접근할 수 있는 모든 곳에서 활용할 수 있다.
기존에는 동일한 queryKey
를 사용하는 여러 컴포넌트에서 개별 useQuery
콜백이 중복 호출되는 문제가 있었다.
그러나 meta
기반의 글로벌 콜백은 이러한 중복 호출을 방지하고, 필요한 경우 한 번만 실행되도록 하여 일관성을 유지할 수 있다.
또한, 개별 쿼리마다 onSuccess
나 onError
콜백을 반복 작성할 필요가 없다.
meta
필드에 필요한 정보를 추가함으로써 동일한 로직을 한 곳에서 중앙 집중적으로 관리할 수 있다.
이는 코드의 중복을 줄이고 유지 보수성을 높이는 데 기여한다.
// queryClient.ts
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (query.meta.errorMessage) {
toast.error(query.meta.errorMessage)
}
},
}),
})
// useTodos.ts
export function useTodos() {
return useQuery({
queryKey: ['todos', 'list'],
queryFn: fetchTodos,
meta: {
errorMessage: 'Failed to fetch todos',
},
})
}
meta
체계적으로 관리하기앞서 설명한 것처럼, meta
필드를 사용하여 쿼리별로 추가 정보를 저장하고 이를 글로벌 콜백에서 활용할 수 있다.
meta
필드를 효과적으로 관리하는 것은 코드의 가독성과 확장성을 높이는 데 중요하다.
meta
변수를 활용하여 중복 관리 최소화각 쿼리는 저마다 다른 meta
정보를 가질 수 있다.
이를 중앙의 QueryClient
설정에서 일관되게 관리하면 중복을 줄이고 코드의 일관성을 유지할 수 있다.
meta
정보 정의쿼리별로 meta
옵션을 사용해 필요한 부수 효과 함수나 데이터를 설정한다.
// metaHandlers.js
export const todosMeta = {
onSuccess: (data) => {
console.log('할 일 목록을 성공적으로 가져왔습니다:', data);
// 추가적인 부수 효과 로직
},
onError: (error) => {
console.error('할 일 목록을 가져오는 중 오류 발생:', error);
// 추가적인 오류 처리 로직
},
};
export const usersMeta = {
onSuccess: (data) => {
console.log('사용자 목록을 성공적으로 가져왔습니다:', data);
// 사용자 쿼리의 부수 효과 로직
},
// onError를 정의하지 않으면 기본 처리 사용
};
이제 useQuery
에서 meta
정보를 설정해 사용한다.
import { useQuery } from '@tanstack/react-query';
import { todosMeta, usersMeta } from './metaHandlers';
function TodoList() {
const { data } = useQuery(['todos'], fetchTodos, {
meta: todosMeta,
});
// 렌더링 로직...
}
function UserList() {
const { data } = useQuery(['users'], fetchUsers, {
meta: usersMeta,
});
// 렌더링 로직...
}
QueryClient
에서 meta
변수 활용하기중앙의 QueryClient
설정에서 각 쿼리의 meta
정보를 활용하여 부수 효과를 처리한다.
이를 통해 코드 중복을 줄이고 모든 쿼리를 체계적으로 관리할 수 있다.
// queryClient.js
import { QueryClient, QueryCache } from '@tanstack/react-query';
import { notifySuccess, notifyError } from './notificationService';
const queryClient = new QueryClient({
queryCache: new QueryCache({
onSuccess: (data, query) => {
const { meta } = query.options;
if (meta?.onSuccess) {
meta.onSuccess(data);
} else {
// 기본 성공 처리 로직
notifySuccess('데이터를 성공적으로 가져왔습니다.');
}
},
onError: (error, query) => {
const { meta } = query.options;
if (meta?.onError) {
meta.onError(error);
} else {
// 기본 오류 처리 로직
notifyError('데이터를 가져오는 중 오류 발생.');
}
},
}),
});
export default queryClient;
TanStack Query v5에서 onSuccess
와 onError
콜백이 제거된 것은 더 단순하고 일관된 API 설계를 추구하는 데 있다.
처음에는 익숙했던 콜백 방식이 사라진 점이 불편하게 느껴질 수도 있다. (discussion의 개발자들처럼..)
하지만 useEffect
, queryClient
의 meta
필드를 통해 부수 효과를 관리하는 새로운 패턴은 코드의 유지 보수성과 확장성을 높이는 데 도움을 준다.
새로운 패턴으로 인해 코드베이스가 커지고 복잡해지더라도 중앙 집중적으로 부수 효과를 관리할 수 있어, 앱의 안정성과 예측 가능성을 높일 수 있다.
이렇게 변화를 적극적으로 수용하고 새로운 방법을 익히면, 더 견고하고 유지보수하기 쉬운 애플리케이션을 만들 수 있을 것이라고 생각한다.
각 쿼리에서 meta 정보 정의해서 관리하고 사용하는 방법도 있었네요 👍🏻