TanStack Query 공식 홈페이지 안에 들어갔을 때 압도적인 문서량에 놀랐다.
그리고 문서의 정리 형식이 생각보다 복잡하게 되어있다고 느꼈다.
FE에서 가장 많이 쓰이는 CRUD를 기준으로 나눠서 글을 작성했다.
TanStack Query hook에는 많은 옵션들이 있지만 가장 많이 쓰이고 핵심 기능인
useQuery
와 useMutation
부터 알아보자
C : useQuery
는 GET
기능에 해당한다.
RUD : useMutation
는 POST/PATCH/DELETE
추가, 수정, 삭제 기능에 해당한다.
useQuery
기본적으로 useQuery
를 통한 쿼리 인스턴스는 캐시된 데이터를 Stale된 것으로 취급한다.
이러한 취급을 바꾸고 싶다면 StaleTime
옵션을 따로 설정해서 해당 쿼리가
Stale
되지 않게 끔 설정이 가능하다.
refetchOnMount
refetchOnWindowFocus
refetchOnReconnect
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>
}
queryKey
querykey
를 기반으로 쿼리 캐싱을 관리한다.queryKey
가 Serializable(직렬화) 기능을 하고, 쿼리의 데이터에 대해 unique
하면 어떤 형태로든 사용할 수 있다는 것.queryKey
는 캐시된 데이터와 비교해 새로운 데이터를 가져올지, 캐시된 데이터를 사용할지 결정하는 기준을 정할 수 있다. Serializable(직렬화)란??
직렬화 가능한 데이터
1. string, number, boolean, array, object
함수나, symbol 또는 순환 참조를 가진 데이터는 불가능!// 가능한 것 ['user', { id: 123 }] // 배열과 객체 조합 ['todos', 1, true] // 불가능한 것 ['user', new Map()] // Map 객체는 JSON으로 직렬화 불가 ['user', () => {}]
queryFn
queryFn
은 문자 그대로 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>}
</>
)
}
refetch
refetch
함수를 사용하면 데이터를 항상 새롭게 다시 가져올 수 있다.
// ...
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
로 지정하는 것이 좋다.
select
select
함수를 사용하면 가져온 데이터를 변형(선택)할 수 있다.
쿼리 함수가 반환하는 데이터를 인수로 받아 선택함수에서 처리하고 반환하면 최종 데이터가 된다.
최종 데이터 타입은 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)
})
// ...
}
useMutation
useMutation
훅을 통해 데이터 변경 작업을 처리하고 다양한 상태를 얻을 수 있다(성공, 실패, 로딩)
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>
)
}
mutate
useMutation
의 속성 중 하나로 mutate
는 useMutation
을 조작할 수 있는 속성 mutate
안에는 useMutation
의 비동기 함수에 들어갈 인자가 들어간다. const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutate({ name, age }) // 변이!
}