Powerful asynchronous state management for TS/JS, React, Solid, Vue, Svelte and Angular
저자는 자신의 블로그에서 Tanstack Query 는 데이터 패치 라이브러리가 아닌, 비동기 상태 관리 툴이라고 소개합니다.
그리고 Tanstack Query는 다음과 같은 문제를 해결하기 위해 만들어졌습니다.
그렇다면 위 문제들을 어떻게 해결했을까요? Tanstack 이 내세우는 세가지 특징을 살펴봅시다.
사용자는 단순히 Tanstack Query에게 “어떤 데이터를 얻고 싶은지”, “얼마나 신선한 상태로 유지할 지”만 알려주면 나머지는 알아서 해줍니다. 캐싱, 백그라운드 업그레이드, 낡은 데이터 처리를 복잡한 설정 없이 알아서 수행합니다.
💡 과정을 표현하는 절차형이 아니라, 원하는 결과를 표현하는 선언형 패러다임과 더불어 상태 관리에 필요한 각종 부수작업들을 알아서 처리해줍니다.
만약 사용자가 프로미스나 async/await 에 알고있다면, 바로 Tanstack Query를 사용할 수 있습니다. 관리해야 할 전역 상태와 리듀서, 정규화 시스템이나 무거운 설정등이 전혀 없습니다. 단순히 패칭 함수만 전달하면 끝입니다!
💡 비동기를 처리할 수 있는 최신 JS 문법에 잘 어우러지고, 무겁고 복잡한 설정 코드를 작성할 필요가 없으므로 코드 표현이 간결해집니다.
Tanstack Query는 각 옵저버별로 세부적인 설정이 가능하며, 다양한 용례에 맞춰 세부 설정이 가능합니다. 개발자 전용 도구와 무한 로딩 API, 일급 변이 도구까지 함께 제공됩니다.
💡 격리된 블랙박스로 인해 다른 영역에 영향을 끼치지 않으므로 모듈성이 좋으며, 비동기 상태 관리를 위한 여러가지 세부 설정이 가능해서 다양한 경우에 맞춰 유연하게 사용할 수 있습니다.
너무 많으니 공식 홈페이지를 참고해주세요.
https://tanstack.com/query/latest
TanStack Query (FKA React Query) is often described as the missing data-fetching library for web applications, but in more technical terms, it makes fetching, caching, synchronizing and updating server state in your web applications a breeze.
대부분의 웹 프레임워크는 자체적인 데이터 패칭 & 업데이트 방법을 제공하지 않기 때문에, 개발자들은 스스로 이러한 기능을 구축해야합니다.
대부분의 전통적인 상태 관리 라이브러리는 클라이언트 상태와는 잘 어울리지만, 비동기 혹은 서버 상태와 작업하기에는 썩 좋지 않습니다. 이것은 클라이언트 상태와 서버 상태가 완전히 다르기 때문입니다.
서버 상태는 보통 다음과 같은 특징을 가지고 있습니다.
이러한 서버 상태를 잘 다루기 위해서는 다음과 같은 작업들이 필요합니다.
이러한 수많은 작업들을 일일이 다루기 힘들기 때문에, Tanstack Query는 이러한 작업들을 알아서 잘 해줍니다.
Tanstack Query가 제공하는 대부분의 기능은 훅의 형태로 제공됩니다. 이러한 훅은 React가 원하는 “재사용 가능한, 비슷한 것을 하는 작은 함수의 묶음을 선언적으로 표현”이 가능합니다.
한 예로 useQuery
내부 구현을 보면, 쿼리 클라이언트와 옵저버를 이용하는 커스텀 훅임을 알 수 있습니다.
Tanstack Query는 컴포넌트에 서버 상태를 연동시킬 수 있습니다. 컴포넌트라는 격리된 공간 내에서 연동된 서버 상태를 간편하게 관리할 수 있습니다.
// 여기서는 글 목록만!
function PostList() {
const { data: postList } = useQuery({
queryKey: ['post'],
queryFn: fetchPostList,
});
}
// 여기서는 글만!
function Post(props) {
const { id, img } = props;
const { data: post } = useQuery({
queryKey: ['Post', id],
queryFn: fetchPost,
});
}
일부 기능들은 Suspense와 함께 사용하도록 설계되어있습니다. 이를 통해 조금 더 선언적으로 UI를 표현할 수 있습니다.
실제로 구현체를 보면, 프로미스를 던지는 것으로 Suspense와 어우러지게 되어있음을 알 수 있습니다.
function useBaseQuery(...) {
// Handle suspense
if (shouldSuspend(defaultedOptions, result)) {
throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary)
}
}
Tanstack Query는 구조적 공유(Structural sharing) 기법을 이용해 렌더링을 최적화합니다. (사실 React 뿐만 아니라, 반응형 프레임워크에서 다 통용되는 원칙이긴 하지만..)
❓ 구조적 공유란? 낡은 서버 상태와 새로운 서버 상태를 비교할 때, 이전 상태를 최대한 많이 유지하려고 하는 전략입니다. JSON 형태로 직렬화 가능한 데이터에 대해, 변경이 없는 부분은 얕은 복사를 통해 레퍼런스를 유지하여 리렌더링을 방지합니다.
QueryClientProvider
를 통해, 리액트 컴포넌트 트리 내에서 손쉽게 QueryClient
를 공유할 수 있습니다. 물론, 이 또한 React 내장 Context API를 이용해 만들어져있습니다.
🚨
QueryClient
는 참조-안전하게 사용하길 권장합니다. 가장 좋은 방법은App
외부에 쿼리 클라이언트 인스턴스를 생성하는 것입니다. (참고)
React 처럼 간단하게 사용하기엔 좋지만 잘 쓰려면 알아야 할 것이 많습니다. (당장 TkDodo 의 블로그 아티클만 봐도 28개나 됩니다. 😨)
❗ 공식 문서에 나와있는 예제는 여기서 설명하지 않도록 하겠습니다.
Treat the query key like a dependency array
쿼리 키를 훅의 의존성 배열처럼 대하라고 합니다. 즉, 다음 두가지가 유사합니다.
아래 예제에서는 모든 할 일 목록을 불러온 다음 필터된 목록을 불러오려고 할 때, initialData
내부에서 getQueryData
를 사용하여, 화면의 깜빡거림을 방지하는 기술을 보여주고 있습니다.
type State = 'all' | 'open' | 'done';
type Todo = {
id: number
state: State
};
type Todos = ReadonlyArray<Todo>;
const fetchTodos = async (state: State): Promise<Todos> => {
const response = await axios.get(`todos/${state}`)
return response.data
};
const useTodosQuery = (state: State) =>
useQuery({
queryKey: ['todos', state],
queryFn: () => fetchTodos(state),
initialData: () => {
const allTodos = queryClient.getQueryData<Todos>([
'todos',
'all',
])
const filteredData =
allTodos?.filter((todo) => todo.state === state) ?? []
return filteredData.length > 0 ? filteredData : undefined
},
});
TanStack Query 가 뱉어주는 서버 상태를 로컬 상태에 저장하는 경우, 이 로컬 상태는 서버 상태의 변경을 따라가지 못합니다. 즉, 둘이 동기화가 되지 않는 부작용이 있습니다.
의도적으로 둘의 동기화를 막고 싶은 경우가 아니라면, 서버 상태의 복사본인 로컬 상태를 사용하지 않는 것이 좋습니다.
다음 코드는 의도적으로 둘의 동기화를 막은 사례인데요, 서버 상태를 가져와 폼의 초기 상태로 설정하는 코드입니다.
const App = () => {
const { data } = useQuery({
queryKey: ['key'],
queryFn,
staleTime: Infinity,
})
return data ? <MyForm initialData={data} /> : null
}
const MyForm = ({ initialData }) => {
const [data, setData] = React.useState(initialData)
...
}
이 경우, 불필요하게 패치 함수가 불리는것을 막기 위해 staleTime
을 Infinity
로 설정해주는 것을 잊지 마세요!
setQueryData
를 이용해 쿼리 데이터를 변경하는 것은 일반적으로 낙관적 업데이트 혹은 서버 상태를 받아온 직후에만 사용하기를 권장하고 있습니다.
암묵적인 백그라운드 패치가 의도적으로 설정한 서버 상태를 덮어씌울 수 있으므로, 패치 함수가 트리거되지 않도록 주의를 기울여야 합니다.
🚨 추가로,
setQueryData
를 사용하는 경우 쿼리 키가 반드시 정확히 일치해야 합니다. (참고)
// 2번에 나온 예제 코드
const useTodosQuery = (state: State) =>
useQuery({
queryKey: ['todos', state],
queryFn: () => fetchTodos(state),
});
단순히 tanstack 훅을 래핑하는 커스텀 훅을 만들어 쓰더라도, 거기엔 여러가지 이점이 있습니다.
쿼리 함수에서 데이터를 변형하는 경우, 네트워크 탭의 응답 값과 TanStack 디버거에 표시되는 값이 달라 혼란을 겪을 수 있습니다. 그리고 패치 함수가 호출될 때 마다 변형이 일어납니다.
// 패치 함수 내에서 서버 상태를 변형하는 경우
const fetchTodos = async (): Promise<Todos> => {
const response = await axios.get('todos')
const data: Todos = response.data
return data.map((todo) => todo.name.toUpperCase())
};
const useTodosQuery = () =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
이런저런 이유로 응답값을 변형해야 하지만 근본적으로 API를 수정할 수 없는 경우, select
옵션이 좋은 해결책이 됩니다. 이는 온전히 클라이언트 사이드에서 변형 로직을 최적화하기 좋은 곳입니다.
셀렉터 함수는 순수 함수로 만들거나 useCallback
을 이용해서 메모이징을 하는 것을 권장합니다.
또한, 5번과 결합해서 유용한 커스텀 훅을 만들어 사용할 수도 있습니다.
// select 옵션을 이용해 서버 상태를 변형하는 경우
const useTodosQuery = (select) =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select,
});
const useTodosCount = () => useTodosQuery((data) => data.length);
const useTodo = (id) => useTodosQuery((data) => data.find((todo) => todo.id === id));
💡 저자는 “불필요한 리렌더링” 보다, “필요한 리렌더링을 놓치는 것”을 더 경계라하고 조언합니다.
종종 TanStack Query 를 사용하다보면 리렌더링이 여러번 일어나는 경우가 있습니다. 이는 TanStack Query에 존재하는 여러 메타데이터가 변경되기 때문입니다.
{ status: 'success', data: 2, isFetching: true }
{ status: 'success', data: 2, isFetching: false }
대부분의 경우 이는 필요한 리렌더링이지만, 만약 극한으로 최적화를 하고 싶다면 notifyOnChangeProps
속성을 이용할 수 있습니다.
다만 이는 정말로 필요한 일부 경우에만 사용하는 것이 좋습니다. 지정한 필드 외에는 상태가 동기화되지 않고, 변경을 감지하지 않기 때문에 필요한 리렌더링을 놓치는 경우가 발생할 수 있기 때문입니다.
사용자가 필요한 값을 일일이 지정하기 귀찮을 수 있으므로, "tracked"
라는 값을 지정할 수 있습니다. 다만 이는 몇가지 한계가 있으므로 조심해서 사용해야합니다. (Tracked Query 의 주의점)
주요한 몇가지 쿼리 상태를 표현하는 메타데이터들에 대해 알아보겠습니다.
success
: 쿼리가 성공해서 data
가 존재하는 경우error
: 쿼리가 실패해서 error
가 존재하는 경우pending
: 캐시된 데이터가 없고, 쿼리가 아직 완료되지 않은 경우isStale
: 캐싱된 데이터가 invalidate 하거나, staleTime
이 지나버린 경우 truefetching
: 초기 pending
상태를 포함해서, 패칭 함수가 실행중이거나 백그라운드 리패칭이 실행중인 경우 truepaused
: 네트워크 오프라인등의 이유로 쿼리 함수를 진행할 수 없는 경우 true. 만약 네트워크가 연결되면 자동으로 패칭을 재시도함.idle
: 쿼리가 일시정지되지 않았으면서, 패칭중이 아닐 때 trueisRefetching
: 초기 pending
상태를 제외하고, 백그라운드 리패칭이 실행중인 경우 true🚨
success
상태와error
상태는 상호배타적이라고 생각하기 쉽지만, 일부 경우에는 공존이 가능합니다. (참고) 저자는 이런 경우 재시도 메커니즘에 의해 안좋은 유저 경험을 제공할 수 있으므로, 데이터 이용가능성을 우선적으로 확인하는 것을 권장합니다.
module augmentation 을 이용해 쿼리 에러의 기본 타입을 지정할 수 있습니다.
declare module '@tanstack/react-query' {
interface Register {
defaultError: AxiosError
}
}
enabled
옵션 대신, skipToken
이라는 함수를 이용해서 type-safe하게 쿼리를 비활성화 할 수 있습니다.
import { useQuery, skipToken } from '@tanstack/query'
// id가 유효한 경우에만 패치 함수를 실행
function useGroup(id: number | undefined) {
return useQuery({
queryKey: ['group', id],
queryFn: () => fetchGroup(id),
enabled: Boolean(id),
})
}
// 마찬가지로 id가 유효한 경우에만 패치 함수를 실행
function useGroup(id: number | undefined) {
return useQuery({
queryKey: ['group', id],
queryFn: id ? () => fetchGroup(id) : skipToken,
})
}
개인적으로는 enabled
옵션을 사용하는 것이 더 좋지만, TS에 더 잘 맞는 부분은 후자인 것 같네요.
Use Query Key factories 목차를 보면, 쿼리 키를 일일이 선언하는 방식에 대한 부작용을 언급하고 있습니다.
This is not only error-prone, but it also makes changes harder in the future
그래서 저자는 쿼리 키 팩토리를 사용하는 것을 권장합니다. (필수는 아니지만, 확장가능한 앱을 위해 권장한다고 합니다.)
const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
}
// 🕺 remove everything related to the todos feature
queryClient.removeQueries({
queryKey: todoKeys.all
})
// 🚀 invalidate all the lists
queryClient.invalidateQueries({
queryKey: todoKeys.lists()
})
// 🙌 prefetch a single todo
queryClient.prefetchQueries({
queryKey: todoKeys.detail(id),
queryFn: () => fetchTodo(id),
})
쿼리 캐시에 데이터를 미리 채워놓는 두가지 방법이 있습니다.
placeholderData
initialData
function Component() {
// ✅ status will be success even if we have not yet fetched data
const { data, status } = useQuery({
queryKey: ['number'],
queryFn: fetchNumber,
placeholderData: 23,
})
// ✅ same goes for initialData
const { data, status } = useQuery({
queryKey: ['number'],
queryFn: fetchNumber,
initialData: () => 42,
})
}
두 방법 모두 pending
상태 없이 바로 success
상태가 됩니다. 또한 처음부터 캐시 데이터가 존재하므로, 이펙트가 없습니다.
하지만 두 방법 간의 차이가 전혀 없는 것은 아닙니다. initialData
는 캐시 레벨에서 동작하며, placeholderData
는 옵저버 레벨에서 동작합니다. 이로인해, 다음과 같은 차이점을 만들어냅니다.
initialData
는 캐시에 영속적으로 존재하며, placeholderData
는 캐시에 영속적으로 존재하지 않습니다.initialData
가 설정된다면, 이후 설정값은 무시됩니다.placeholderData
는 (컴포넌트 별로) 하나의 쿼리 키에 여러 값을 지닐 수 있습니다.placeholderData
는 가짜 값이기 때문에 항상 백그라운드 리패칭이 수행되며, 실제 쿼리 데이터가 받아와지면 교체됩니다. 반면에 initialData
는 진짜 값이기 때문에 staleTime
설정에 따라 백그라운드 리패칭이 수행되지 않을 수 있습니다.💡 저자의 추천 방식! 다른 쿼리로부터 초기 값을 설정해야 할 땐
initialData
를, 그 외엔placeholderData
를 쓴다고 합니다.
🚨
initialData
는 기본적으로 신선한 데이터로 판단하기 때문에, 낡아지는 시점을 잘 지정해주는게 좋습니다. 만약 쿼리 캐시로부터initialData
를 설정한다면,initialDataUpdatedAt
속성을 같이 지정해주세요.
첫번째로는 React 에서 제공하는 useDifferValue
를 사용하는 방법입니다. 이 방법은 React 환경에서 모든 비동기 작업에 적용할 수 있습니다.
function App() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<>
<label>
Search albums:
<input value={query} onChange={e => setQuery(e.target.value)} />
</label>
<Suspense fallback={<h2>Loading...</h2>}>
<SearchResults query={deferredQuery} />
</Suspense>
</>
);
}
function SearchResults({ query }) {
const { data: albums } = useSuspenseQuery({
queryKey: [...],
queryFn: () => fetchAlbums(query),
});
return (
<ul>
{albums.map(album => (
<li key={album.id}>
{album.title} ({album.year})
</li>
))}
</ul>
);
}
두번째 방법은 TanstackQuery 에서 제공하는 keepPreviousData
를 사용하는 방법입니다.
import { keepPreviousData } from '@tanstack/react-query'
const { data, isPlaceholderData } = useQuery({
queryKey: ['item', id],
queryFn: () => fetchItem({ id }),
// ⬇️ like this️
placeholderData: keepPreviousData,
})
TanStack Query 에서 에러를 핸들링하는 방법에는 세가지가 있습니다.
// 훅을 호출하는 주체가 에러를 직접 처리하는 방법
function TodoList() {
const todos = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos
})
// isError 를 이용해 판단할 수도 있지만, 8번의 "데이터 이용가능성"을 확인하는게 더 좋습니다.
if (todos.isError) {
return 'An error occurred'
}
}
// 에러를 던지는 방법
function TodoList() {
const todos = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
// 해당 옵션에 true 를 주거나,
throwOnError: true,
// 함수를 줄 수 있습니다.
throwOnError: (error) => error.response?.status >= 500,
})
}
// 에러 콜백을 이용하는 방법
const useTodos = () =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
// ⚠️ looks good, but is maybe _not_ what you want
onError: (error) =>
toast.error(`Something went wrong: ${error.message}`),
})
다만 세번째 방법은 useTodos
를 호출하는 모든 컴포넌트가 onError
함수를 호출하게되므로, 토스트가 여러개 뜰 수 있습니다. 단 한번만 에러 핸들링 동작이 수행되기를 원한다면, QueryCache
를 이용하면 됩니다.
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) =>
toast.error(`Something went wrong: ${error.message}`),
}),
})
이 방식을 이용하면 하나의 쿼리 키에 한번의 에러 핸들링이 보장됩니다. 그래서 Sentry 등의 에러 로깅 도구를 넣기 좋은 위치입니다.
useQuery is declarative, useMutation is imperative.
function AddComment({ id }) {
const addComment = useMutation({
mutationFn: (newComment) =>
axios.post(`/posts/${id}/comments`, newComment),
})
return (
<form
onSubmit={(event) => {
event.preventDefault()
addComment.mutate(
new FormData(event.currentTarget).get('comment')
)
}}
>
<textarea name="comment" />
<button type="submit">Comment</button>
</form>
)
}
위와 같이 변이 함수를 호출하면 됩니다.
여기서 더 나아가서, 변이된 서버 상태를 다시 동기화해주는 두가지 방법이 있습니다.
// 캐시 무효화
const useAddComment = (id) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ...,
onSuccess: () => {
// ✅ refetch the comments list for our blog post
queryClient.invalidateQueries({
queryKey: ['posts', id, 'comments']
})
},
})
}
// 직접 캐시 업데이트
const useUpdateTitle = (id) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ...,
// 💡 response of the mutation is passed to onSuccess
onSuccess: (newPost) => {
// ✅ update detail view directly
queryClient.setQueryData(['posts', id], newPost)
},
})
}
만약 변이 함수가 필요한 모든 서버 상태를 반환하는 등, 당신이 리패칭을 하기 원하지 않는다면, 두번째 방법을 추천드립니다.
💡 onSuccess 함수는 async 함수를 받을 수 있습니다. 만약 쿼리 무효화를 기다리게 하고 싶다면,
invalidateQueries
가 반환하는 프로미스를 다시 반환하세요.
💡 프로미스를 반환하는
mutateAsync
함수도 있습니다.
개인적으로 Tanstack Query 와 React 컴포넌트가 어떻게 상호작용을 하며 생명주기가 흘러가는지 이해하는게 중요하다고 생각합니다.
위 이미지는 컴포넌트가 마운트되는 시점부터 리렌더링까지의 흐름을 간략하게 도식화한 그림입니다. 간략한 과정은 다음과 같습니다.