TanStack Query 공식 홈페이지 안에 들어갔을 때 압도적인 문서량에 놀랐다.
그리고 문서의 정리 형식이 생각보다 복잡하게 되어있다고 느꼈다.
FE에서 가장 많이 쓰이는 CRUD를 기준으로 나눠서 글을 작성했다.
TanStack Query hook에는 많은 옵션들이 있지만 가장 많이 쓰이고 핵심 기능인
useQuery와 useMutation부터 알아보자
C : useQuery는 GET 기능에 해당한다.
RUD : useMutation 는 POST/PATCH/DELETE 추가, 수정, 삭제 기능에 해당한다.

useQuery기본적으로 useQuery를 통한 쿼리 인스턴스는 캐시된 데이터를 Stale된 것으로 취급한다.
이러한 취급을 바꾸고 싶다면 StaleTime 옵션을 따로 설정해서 해당 쿼리가
Stale되지 않게 끔 설정이 가능하다.
refetchOnMountrefetchOnWindowFocusrefetchOnReconnect refetchInterval기본적으로 'inactive(비활성화)' 쿼리는 5분 후에 garbage로 수집된다.
이를 변경하려면 gcTime을 설정해주면 된다. 기본값 : 1000 * 60 * 5
import { useQuery } from '@tanstack/react-query'
type ResponseValue = {
message: string
time: string
}
export default function DelayedData() {
const { data } = useQuery<ResponseValue>({
queryKey: ['delay'],
queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(),
staleTime: 1000 * 10 // 10초
})
return <div>{data?.time}</div>
}
queryKeyquerykey를 기반으로 쿼리 캐싱을 관리한다.queryKey가 Serializable(직렬화) 기능을 하고, 쿼리의 데이터에 대해 unique하면 어떤 형태로든 사용할 수 있다는 것.queryKey는 캐시된 데이터와 비교해 새로운 데이터를 가져올지, 캐시된 데이터를 사용할지 결정하는 기준을 정할 수 있다. Serializable(직렬화)란??
직렬화 가능한 데이터
1. string, number, boolean, array, object
함수나, symbol 또는 순환 참조를 가진 데이터는 불가능!// 가능한 것 ['user', { id: 123 }] // 배열과 객체 조합 ['todos', 1, true] // 불가능한 것 ['user', new Map()] // Map 객체는 JSON으로 직렬화 불가 ['user', () => {}]
queryFnqueryFn은 문자 그대로 Promise를 반환하는 모든 함수가 될 수 있다.(비동기 함수)throw Error를 해야한다. → 에러의 기본 값은 null이다.import { useQuery } from '@tanstack/react-query'
type ResponseValue = {
message: string
time: string
}
export default function DelayedData() {
const { data, error } = useQuery<ResponseValue>({
queryKey: ['delay'],
queryFn: async () => {
const res = await fetch('https://api.heropy.dev/v0/delay?t=1000')
const data = await res.json()
if (!data.time) {
throw new Error('문제가 발생했습니다!')
}
return data
},
staleTime: 1000 * 10,
retry: 1
})
return (
<>
{data && <div>{JSON.stringify(data)}</div>}
{error && <div>{error.message}</div>}
</>
)
}
refetchrefetch함수를 사용하면 데이터를 항상 새롭게 다시 가져올 수 있다.
// ...
export default function DelayedData() {
const { data, isStale, refetch } = useQuery<ResponseValue>({
queryKey: ['delay'],
queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(),
staleTime: 1000 * 10
})
return (
<>
<div>{data?.time}</div>
<div>데이터가 상했나요?: {JSON.stringify(isStale)}</div>
<button onClick={() => refetch()}>데이터 가져오기!</button>
</>
)
}
queryClient.fetchQuery()메서드를 사용할 수 있다.queryKey와 staleTime을 기존 쿼리와 동일하게 제공해야 한다.(queryFn은 생략 가능)
queryOptions 함수를 사용해 옵션을 미리 정의하고 재사용할 수 있다.
import { useQuery, useQueryClient, queryOptions } from '@tanstack/react-query'
// ...
const options = queryOptions<ResponseValue>({
queryKey: ['delay'],
queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(),
staleTime: 1000 * 10
})
export default function DelayedData() {
const queryClient = useQueryClient()
const { data, isStale } = useQuery(options)
function fetchData() {
const data = await queryClient.fetchQuery(options)
console.log(data) // 캐시된 데이터 or 새로 가져온 데이터
}
return (
<>
<div>{data?.time}</div>
<div>데이터가 상했나요?: {JSON.stringify(isStale)}</div>
<button onClick={fetchData}>데이터 가져오기!</button>
</>
)
}
만약 캐시된 데이터가 필요하다면, queryClient.getQueryData() 메소드를 사용할 수 있다.
데이터가 상해도 새로 가져오지 않고, 캐시된 데이터만 반환
캐시된 데이터가 없는 경우, undefined를 반환.
queryClient.getQueryData()대신queryClient.ensureQueryData()메소드를 사용하면, 캐시된 데이터가 없는 경우undefined를 반환하지 않고, 자동으로queryClient.fetchQuery()메소드를 호출해 데이터를 가져온다.
useQueryClient 훅을 사용해 queryClient 객체를 가져온 후, getQueryData 메소드를 사용
import { useQuery, useQueryClient } from '@tanstack/react-query'
// ...
export default function DelayedData() {
const queryClient = useQueryClient()
const { data, isStale } = useQuery<ResponseValue>({
queryKey: ['delay'],
queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(),
staleTime: 1000 * 10
})
function getCachedData() {
const data = queryClient.getQueryData(['delay'])
console.log(data) // 캐시된 데이터 or undefined
}
return (
<>
<div>{data?.time}</div>
<div>데이터가 상했나요?: {JSON.stringify(isStale)}</div>
<button onClick={getCachedData}>데이터 가져오기!</button>
</>
)
}
참고
- 이러한 최적화는
queryFn이 JSON 호환 데이터를 반환하는 경우에만 작동structuralSharing: false로 설정하면 전역적으로 또는 개별 쿼리에서 이 기능을 비활성화할 수 있다.structuralSharing통해 메모리 사용량을 최적화하고 불필요한 리렌더링을 방지할 수 있지만
때로는structuralSharing옵션을false로 지정하는게 더 유리할 수 있다.
WHY?
매우 큰 중첩 객체를 다루는 경우 구조적인 비교 자체가 성능에 부담이 될 수 있다.
또한 데이터가 항상 새로운 참조여야 하거나 데이터가 단순해 깊은 비교가 필요하지 않는 경우에도false로 지정하는 것이 좋다.
selectselect함수를 사용하면 가져온 데이터를 변형(선택)할 수 있다.
쿼리 함수가 반환하는 데이터를 인수로 받아 선택함수에서 처리하고 반환하면 최종 데이터가 된다.
최종 데이터 타입은 useQuery의 3번째 제네릭 타입으로 선언할 수 있다.
import { useQuery } from '@tanstack/react-query'
type Users = User[]
interface User {
id: string
name: string
age: number
}
export default function UserNames() {
const { data } = useQuery<Users, Error, string[]>({
queryKey: ['users'],
queryFn: async () => {
const res = await fetch('https://api.heropy.dev/v0/users')
const { users } = await res.json()
return users
},
staleTime: 1000 * 10,
select: data => data.map(user => user.name)
})
return (
<>
<h2>User Names</h2>
<ul>{data?.map((name, i) => <li key={i}>{name}</li>)}</ul>
</>
)
}
쿼리 함수를 따로 선언해 제공하면, 선택 함수를 통한 최종 데이터의 타입을 추론할 수 있다.
다음 예제에서 쿼리 함수의 반환은 Users 타입이고, 최종 데이터(선택 함수의 반환)은 string[] 타입으로 추론
// ...
async function queryFn(): Promise<Users> {
const res = await fetch('https://api.heropy.dev/v0/users')
const { users } = await res.json()
return users
}
export default function UserNames() {
// data는 string[] 타입으로 추론
const { data } = useQuery({
queryKey: ['users'],
queryFn,
staleTime: 1000 * 10,
select: data => data.map(user => user.name)
})
// ...
}
useMutationuseMutation훅을 통해 데이터 변경 작업을 처리하고 다양한 상태를 얻을 수 있다(성공, 실패, 로딩)
useQuery와 비슷하지만 queryKey가 안들어 감
const { data, isLoading, mutate, mutateAsync } = useMutation(mutationFn, options);
mutationFn : 비동기 함수
useMutation에서 가장 많이 쓰이는 2가지 옵션
Optimistic Update
서버 요청의 응답을 기다리지 않고, 먼저 UI를 업데이트하는 기능
서버 응답이 느린 상황에서도 빠른 인터페이스를 제공할 수 있어 사용자 경험을 크게 향상
invalidateQueries
useQuery에서 사용되는queryKey의 캐시 데이터를 제거하고
캐시가 없기 때문에staleTime,cacheTime을 무시하고 무조건 새로운 데이터를 가져올 수 있게 됨
import { useQuery } from '@tanstack/react-query'
export type Users = User[]
export interface User {
name: string
age: number
isValid?: boolean
emails?: string[]
photo?: {
name: string
data: string // Base64
}
id?: string
}
export default function Users() {
const { data } = useQuery<Users>({
queryKey: ['users'],
queryFn: async () => {
const res = await fetch('https://api.heropy.dev/v0/users') // GET
const json = await res.json()
return json.users
},
staleTime: 1000 * 60 * 5 // 5분
})
// ...
}
다음 예제는 입력한 사용자의 이름과 나이로 새로운 사용자를 추가하는 예제
import React, { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { Users, User } from './Users'
export default function AddUser() {
const [name, setName] = useState('')
const [age, setAge] = useState(0)
const queryClient = useQueryClient()
const { mutate, error, isPending, isError } = useMutation({
mutationFn: async (newUser: User) => { //
const res = await fetch('https://api.heropy.dev/v0/users', {
method: 'POST',
body: JSON.stringify(newUser)
})
if (!res.ok) throw new Error('변이 중 에러 발생!') // 변이 실패!
return res.json() // 변이 성공!
},
onMutate: async newUser => {
// 낙관적 업데이트 전에 사용자 목록 쿼리를 취소해 잠재적인 충돌 방지!
await queryClient.cancelQueries({ queryKey: ['users'] })
// 캐시된 데이터(사용자 목록) 가져오기!
const previousUsers = queryClient.getQueryData<Users>(['users'])
// 낙관적 업데이트
if (previousUsers) {
queryClient.setQueryData<Users>(['users'], [...previousUsers, newUser])
}
// 각 콜백의 context로 전달할 데이터 반환!
return { previousUsers }
},
onSuccess: (data, newUser, context) => {
console.log('onSuccess', data, newUser, context)
// 변이 성공 시 캐시 무효화로 사용자 목록 데이터 갱신!
queryClient.invalidateQueries({ queryKey: ['users'] })
},
onError: (error, newUser, context) => {
console.log('onError', error, newUser, context)
// 변이 실패 시, 낙관적 업데이트 결과를 이전 사용자 목록으로 되돌리기!
if (context) {
queryClient.setQueryData(['users'], context.previousUsers)
}
},
onSettled: (data, error, newUser, context) => {
console.log('onSettled', data, error, newUser, context)
},
retry: 3, // 변이 실패 시 3번 재시도
retryDelay: 500 // 0.5초 간격으로 재시도
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutate({ name, age }) // 변이!
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="사용자 이름"
/>
<input
type="number"
value={age || ''}
onChange={e => setAge(Number.parseInt(e.target.value, 10))}
placeholder="사용자 나이"
/>
<button
type="submit"
disabled={isPending}>
{isPending ? '사용자 추가 중..' : '사용자 추가하기!'}
</button>
{isError && <p>에러 발생: {error.message}</p>}
</form>
)
}
mutateuseMutation의 속성 중 하나로 mutate는 useMutation을 조작할 수 있는 속성 mutate안에는 useMutation의 비동기 함수에 들어갈 인자가 들어간다. const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutate({ name, age }) // 변이!
}