99명

최근 30분간 동시 방문자 수를 표시합니다. (~)

최고 동시 방문자 수 -
어제: 0명 / 오늘: 0명

TanStack Query(React Query) 핵심 정리

TanStack Query(React Query) 핵심 정리

이 글은 TanStack Query(React Query) 5.102.8 버전을 기준으로 작성되었습니다.

# 개요

TanStack Query는 서버로부터 데이터 가져오기, 데이터 캐싱, 캐시 제어 등 데이터를 쉽고 효율적으로 관리할 수 있는 라이브러리입니다.
React Query라는 이름으로 시작했지만, v4부터 Vue나 Svelte 등의 다른 프레임워크에서도 활용할 수 있도록 기능이 확장되며 TanStack Query라는 이름으로 변경되었습니다.

TanStack Logo

대표적인 기능은 다음과 같습니다.

  • 데이터 가져오기 및 캐싱
  • 동일 요청의 중복 제거
  • 신선한 데이터 유지
  • 무한 스크롤, 페이지네이션 등의 성능 최적화
  • 네트워크 재연결, 요청 실패 등의 자동 갱신

# 데이터 캐싱

TanStack Query를 활용해서 데이터를 가져올 때는 항상 쿼리 키(queryKey)를 지정하게 됩니다.
이 쿼리 키는 캐시된 데이터와 비교해 새로운 데이터를 가져올지, 캐시된 데이터를 사용할지 결정하는 기준이 됩니다.

TSX
1
2
3
4
5
6
7
8
9
import { useQuery } from '@tanstack/react-query' export default function DelayedData() { const { data } = useQuery({ queryKey: ['delay'], queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json() }) return <div>{JSON.stringify(data)}</div> }

다음 이미지는 쿼리 키와 일치하는 캐시된 데이터가 없을 때, 서버에서 새로운 데이터를 가져오는 과정을 보여줍니다.
서버에서 데이터를 가져오면 그 데이터는 캐시되고 그 이후 요청부터는 캐시된 데이터를 사용할 수 있습니다.

캐시된 데이터가 없을 때(Miss)

반대로 쿼리 키와 일치하는 캐시된 데이터가 있으면, 서버에 요청하지 않고 캐시된 데이터를 사용하게 됩니다.
따라서 같은 데이터를 가져오는 요청이 여러 번 발생해도, 캐시된 데이터를 사용하게 되어 중복 요청을 줄일 수 있습니다.
다만 기본 옵션으로는 캐시된 데이터를 곧바로 상한 것으로 취급하기 때문에, 화면에는 캐시된 데이터를 보여주면서 뒤에서는 새로운 데이터를 다시 가져옵니다.
그렇다면 캐시된 데이터를 언제까지 사용할지는 어떻게 정할 수 있을까요?

캐시된 데이터가 있을 때(Hit)

# 데이터의 신선도

TanStack Query는 캐시한 데이터를 신선(Fresh)하거나 상한(Stale) 상태로 구분해 관리합니다.
캐시된 데이터가 신선하다면 서버에 요청하지 않고 캐시된 데이터를 그대로 사용합니다.
반면 데이터가 상하면 다시 가져올 수 있는 상태가 되고, 이때 컴포넌트가 새로 연결되거나 브라우저 화면이 다시 포커스되거나 네트워크가 재연결되면 서버에 다시 요청해 새로운 데이터를 가져옵니다.
데이터가 상했다고 해서 그 즉시 요청을 보내지는 않습니다.
데이터의 신선도는 일종의 유통기한으로 생각하면 이해하기 쉽습니다.

신선한 데이터와 상한 데이터를 사과에 비유

데이터가 상하는 데까지 걸리는 시간은 staleTime 옵션으로 지정할 수 있습니다.
그리고 신선한지 상했는지 여부는 isStale로 확인할 수 있습니다.

TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useQuery } from '@tanstack/react-query' export default function DelayedData() { const { data, isStale } = useQuery({ queryKey: ['delay'], queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(), staleTime: 1000 * 10 // 10초 후 상함. 즉, 10초 동안 신선함. }) return ( <> <div>데이터가 {isStale ? '상했어요..' : '신선해요!'}</div> <div>{JSON.stringify(data)}</div> </> ) }
화면에서 10초 후를 확인해 보세요!

한 번 받으면 바뀌지 않는 데이터라면, staleTime에 시간 대신 'static'을 지정할 수 있습니다.
'static'인 데이터는 절대 상하지 않고, 컴포넌트 연결이나 화면 포커스 같은 자동 갱신 조건에서도 다시 가져오지 않습니다.
Infinity를 지정하면 queryClient.invalidateQueries()로 무효화했을 때는 다시 가져오지만, 'static'은 무효화해도 다시 가져오지 않습니다.

TSX
1
2
3
4
5
6
7
8
export default function DelayedData() { const { data } = useQuery({ queryKey: ['delay'], queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(), staleTime: 'static' // 절대 상하지 않음. }) return <div>{JSON.stringify(data)}</div> }

# 설치 및 구성

기본 프로젝트는 React 프로젝트 시작하기 w. Vite를 참고하세요.

다음과 같이 TanStack Query를 설치합니다.
TanStack Query ESLint 플러그인을 사용할 수도 있습니다.

BASH
1
2
npm i @tanstack/react-query npm i -D @tanstack/eslint-plugin-query

ESLint 플러그인의 권장 규칙을 사용하면, 일반적인 실수를 피하는 데 도움이 됩니다.
Flat Config(eslint.config.js)에서는 configs['flat/recommended']를 전개해 추가합니다.
이 값은 설정 객체의 배열이므로 전개 연산자(...)가 필요합니다.

/eslint.config.js
JS
1
2
3
4
5
import tanstackQuery from '@tanstack/eslint-plugin-query' export default [ ...tanstackQuery.configs['flat/recommended'] ]
ESLint 구성 예시

configs.recommended는 .eslintrc 방식 전용이라 Flat Config에서는 동작하지 않습니다.
그리고 .eslintrc.* 파일 자체가 ESLint 10부터 지원이 제거됐으므로, Flat Config를 사용하세요.

권장 규칙(flat/recommended)은 다음 7가지입니다.

  • @tanstack/query/exhaustive-deps: 쿼리 함수에서 사용하는 외부 변수는 쿼리 키에 추가하세요!
  • @tanstack/query/stable-query-client: 애플리케이션에서 하나의 쿼리 클라이언트를 생성해 사용하세요!
  • @tanstack/query/no-rest-destructuring: 쿼리의 반환에서 나머지 매개변수(...rest)를 사용하지 마세요!
  • @tanstack/query/no-unstable-deps: 쿼리의 반환을 React 훅의 의존성 배열에 그대로 넣지 마세요! 구조 분해한 값을 넣으세요!
  • @tanstack/query/infinite-query-property-order: 타입 추론을 위해 queryFn, getPreviousPageParam, getNextPageParam 순서로 작성하세요!
  • @tanstack/query/no-void-query-fn: 쿼리 함수는 반드시 값을 반환하세요!
  • @tanstack/query/mutation-property-order: 타입 추론을 위해 onMutate, onError, onSettled 순서로 작성하세요!

더 엄격하게 검사하려면 flat/recommended-strict를 사용할 수 있습니다.
queryKey와 queryFn을 queryOptions()로 묶도록 권장하는 @tanstack/query/prefer-query-options 규칙이 추가됩니다.

프로젝트 범위를 <QueryClientProvider>로 랩핑하고, 사용할 queryClient 인스턴스를 연결합니다.
이제 사용할 준비가 끝났습니다!

/src/App.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { QueryClient, QueryClientProvider, } from '@tanstack/react-query' import DelayedData from '@/components/DelayedData' const queryClient = new QueryClient() export default function App() { return ( <QueryClientProvider client={queryClient}> <DelayedData /> </QueryClientProvider> ) }

# 핵심 기능

# useQuery

가장 기본적인 쿼리 훅으로, 컴포넌트에서 데이터를 가져올 때 사용합니다.

TSX
1
const 반환 = useQuery<데이터타입>(옵션)

다음 예제의 지연 응답 API는 t 파라미터 값의 시간이 지난 후 응답합니다.
응답 데이터는 간단한 메시지(message)와 응답 시간(time)을 포함합니다.

/src/components/DelayedData.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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> }

# 옵션

다음은 알파벳 순으로 정렬한 목록입니다.

옵션 설명 기본값 타입
enabled 쿼리 자동 실행 여부.
false인 경우, 대기 상태(pending)로 시작.
true boolean | (query: Query) => boolean
gcTime 비활성 캐시 데이터(Inactive)가 메모리에 남아 있는 시간(ms). 5 * 60 * 1000 number | Infinity
initialData 쿼리가 생성되거나 캐시되기 전에 사용하는 초기 데이터. TData | () => TData
initialDataUpdatedAt 초기 데이터의 마지막 업데이트 시간 지정. number | (() => number | undefined)
meta 활용할 추가 정보를 지정. Record<string, unknown>
networkMode 네트워크 모드 지정. 'online' 'online' | 'always' | 'offlineFirst'
notifyOnChangeProps 컴포넌트 리렌더링을 위해 변경 여부를 확인할 쿼리의 특정 반환 속성 목록.
예시: ['data', 'error']
컴포넌트에서 접근한 반환 속성 string[] | "all" | (() => string[] | "all")
placeholderData 대기(Pending) 중인 상태에서 사용할 데이터. TData | (previousValue: TData | undefined, previousQuery: Query | undefined) => TData
queryFn 데이터를 가져오는 쿼리 함수로, 꼭 데이터를 반환하거나 오류를 던져야 함.
기본 쿼리 함수가 지정되지 않은 경우에만 필수 옵션!
((context: QueryFunctionContext) => Promise<TData>) | SkipToken
queryKey 고유한 쿼리 키(식별자).
필수 옵션!
unknown[]
queryKeyHashFn 쿼리 키를 해시하는 함수. (queryKey: QueryKey) => string
refetchInterval 데이터 자동 갱신(다시 가져오기)의 시간 간격(ms). false number | false | ((query: Query) => number | false | undefined)
refetchIntervalInBackground 백그라운드에서 데이터 자동 갱신 여부. false boolean
refetchOnMount useQuery 연결 시 데이터 갱신 여부.
- true: 연결 시 데이터가 상한 경우만 갱신.
- always: 연결 시 데이터 항상 갱신.
true boolean | "always" | ((query: Query) => boolean | "always")
refetchOnReconnect 네트워크 재연결 시 데이터 갱신 여부. true boolean | "always" | ((query: Query) => boolean | "always")
refetchOnWindowFocus 브라우저 화면 포커스 시 데이터 갱신 여부. true boolean | "always" | ((query: Query) => boolean | "always")
retry 쿼리 실패 시 재시도 횟수. 3 boolean | number | (failureCount: number, error: TError) => boolean
retryDelay 재시도 시간 간격(ms).
기본값은 재시도할수록 늘어나는 지수 백오프.
Math.min(1000 * 2 ** 실패횟수, 30000) number | (retryAttempt: number, error: TError) => number
retryOnMount useQuery 연결 시 재시도 여부. true boolean | ((query: Query) => boolean)
select 가져온 데이터를 변형(선택)하는 함수. (data: TData) => unknown
staleTime 데이터가 상하는데 걸리는 시간(ms).
'static'은 절대 상하지 않음.(무효화해도 다시 가져오지 않음)
0 number | 'static' | ((query: Query) => number | 'static')
structuralSharing 데이터 구조의 재사용을 최적화해, 불변성을 유지하고 불필요한 리렌더링 방지. true boolean | (oldData: unknown | undefined, newData: unknown) => unknown
subscribed 쿼리 캐시 구독 여부.
false면 스스로 queryFn을 실행하지 않고, 다른 곳에서 갱신된 캐시도 전달받지 않음.
true boolean
throwOnError 쿼리 실패 시 오류를 던질지 여부. false undefined | boolean | (error: TError, query: Query) => boolean

서버 렌더링(SSR) 환경에서는 gcTime 기본값이 Infinity, retry 기본값이 0으로 다르게 적용됩니다.

쿼리 클라이언트를 직접 지정하고 싶다면, useQuery의 두 번째 인수로 전달합니다.
생략하면 가장 가까운 <QueryClientProvider>의 클라이언트를 사용합니다.

TSX
1
const 반환 = useQuery(옵션, 쿼리클라이언트)
# queryKey

쿼리 키(queryKey)는 쿼리를 식별하는 고유한 값으로, 배열 형태로 지정합니다.
다중 아이템 쿼리 키를 사용할 때는, 아이템의 순서가 중요합니다.

TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 단일 아이템 쿼리 키 useQuery({ queryKey: ['hello'] }) // 다중 아이템 쿼리 키 useQuery({ queryKey: ['hello', 'world', 123, { a: 1, b: 2 }] }) // 서로 같은 쿼리 useQuery({ queryKey: ['hello', 'world', 123, { a: 1, b: 2 }] }) useQuery({ queryKey: ['hello', 'world', 123, { b: 2, c: undefined, a: 1 }] }) // 서로 다른 쿼리 useQuery({ queryKey: ['hello', 'world', 123, { a: 1, b: 2 }] }) useQuery({ queryKey: ['hello', 'world', 123, { a: 1, b: 2, c: 3 }] }) useQuery({ queryKey: ['hello', 'world'] }) useQuery({ queryKey: [123, 'world', { a: 1, b: 2, c: 3 }, 'hello'] })

쿼리 키는 JSON.stringify로 문자열화해서 비교하며, 이때 객체의 속성은 이름순으로 정렬됩니다.
그래서 객체 속성의 순서는 상관없고, 값이 undefined인 속성은 아예 빠집니다.
반면 배열 아이템 자리의 undefined는 null로 바뀌기 때문에 사라지지 않습니다.
따라서 ['delay', undefined]와 ['delay', null]은 같은 쿼리로 취급되니 주의하세요.

다음 예제에서 DelayedData 컴포넌트의 wait Prop의 값이 다르면, 각각 별개의 요청을 전송합니다.

/src/components/DelayedData.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useQuery } from '@tanstack/react-query' type ResponseValue = { message: string time: string } export default function DelayedData({ wait = 1000 }: { wait?: number }) { const { data } = useQuery<ResponseValue>({ queryKey: ['delay', wait], queryFn: async () => (await fetch(`https://api.heropy.dev/v0/delay?t=${wait}`)).json(), staleTime: 1000 * 10 }) return <div>{data?.time}</div> }
/src/App.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import DelayedData from '@/components/DelayedData' const queryClient = new QueryClient() export default function App() { return ( <QueryClientProvider client={queryClient}> <DelayedData /> <DelayedData wait={2000} /> <DelayedData wait={3000} /> </QueryClientProvider> ) }

기본적으로 쿼리 함수(queryFn)에서 사용하는 변수는 쿼리 키에 포함돼야 합니다.
그러면 변수가 변경될 때마다 자동으로 다시 가져올 수 있습니다.
그런데 만약 변수와는 상관없이 항상 하나의 쿼리로 처리하고 싶다면, ESLint exhaustive-deps 규칙을 비활성화할 수 있습니다.

/src/components/DelayedData.tsx
TSX
1
2
3
4
5
6
7
8
9
10
// ... export default function DelayedData({ wait = 1000 }: { wait?: number }) { // eslint-disable-next-line @tanstack/query/exhaustive-deps const { data } = useQuery<ResponseValue>({ // ESLint Error - The following dependencies are missing in your queryKey: wait queryKey: ['delay'], queryFn: async () => (await fetch(`https://api.heropy.dev/v0/delay?t=${wait}`)).json(), staleTime: 1000 * 10 }) return <div>{data?.time}</div> }

프로젝트에서 특정 규칙을 비활성화할 수도 있습니다.

/eslint.config.js
JS
1
2
3
4
5
6
7
8
9
10
import tanstackQuery from '@tanstack/eslint-plugin-query' export default [ ...tanstackQuery.configs['flat/recommended'], { rules: { '@tanstack/query/exhaustive-deps': 'off' } } ]

규칙을 통째로 끄는 대신, 특정 변수만 예외로 지정할 수도 있습니다.
allowlist.variables에 변수 이름을, allowlist.types에 타입 이름을 지정하면 쿼리 키에 없어도 오류로 잡지 않습니다.

/eslint.config.js
JS
1
2
3
4
5
6
7
8
9
10
import tanstackQuery from '@tanstack/eslint-plugin-query' export default [ ...tanstackQuery.configs['flat/recommended'], { rules: { '@tanstack/query/exhaustive-deps': ['error', { allowlist: { variables: ['wait'] } }] } } ]
# queryFn

쿼리 함수(queryFn)는 데이터를 가져오는 비동기 함수로, 꼭 데이터를 반환하거나 오류를 던져야 합니다.
던져진 오류는 반환되는 error 객체로 확인할 수 있습니다.
error는 기본적으로 null입니다.
다만 error는 재시도(retry)를 모두 소진한 뒤에 채워집니다.
재시도가 남아 있는 동안에는 error가 null이고 상태도 대기(pending)이므로, 진행 중인 실패는 failureCount와 failureReason으로 확인합니다.

쿼리 함수는 컨텍스트 객체 하나를 인수로 받습니다.

  • queryKey: 이 쿼리의 쿼리 키
  • signal: 요청 취소에 사용하는 AbortSignal
  • client: 쿼리 클라이언트 인스턴스
  • meta: meta 옵션으로 지정한 추가 정보
  • pageParam: 페이지 번호(useInfiniteQuery에서만)

signal을 fetch 함수에 전달하면, 쿼리가 무효화되거나 컴포넌트가 연결 해제될 때 요청이 자동으로 취소됩니다.

TSX
1
2
3
4
useQuery({ queryKey: ['users'], queryFn: async ({ signal }) => (await fetch('https://api.heropy.dev/v0/users', { signal })).json() })

Axios를 사용한다면 요청 구성 객체의 signal 옵션으로 전달합니다.
fetch 함수와 같은 표준 AbortSignal을 사용하므로, 옵션 이름과 동작이 동일합니다.

TSX
1
2
3
4
5
useQuery({ queryKey: ['users'], queryFn: async ({ signal }) => (await axios.get('https://api.heropy.dev/v0/users', { signal })).data })
/src/components/DelayedData.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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>} </> ) }

쿼리를 조건부로 실행할 때는 enabled 옵션 대신 skipToken을 지정할 수도 있습니다.
쿼리 함수를 실행하지 않는 동작 자체는 enabled: false와 같지만, 타입 추론에서 차이가 있습니다.

다음 예제의 userId는 아직 값이 없을 수 있으므로 string | undefined 타입이고, fetchUser 함수는 string 타입의 인수를 요구합니다.
enabled는 쿼리 함수와는 별개의 옵션이므로, enabled가 참일 때만 쿼리 함수가 실행된다는 사실을 TypeScript가 알지 못합니다.
따라서 쿼리 함수 안의 userId는 여전히 string | undefined로 남고, userId!처럼 값이 있다고 단언해야 합니다.

TSX
1
2
3
4
5
6
7
8
// fetchUser(id: string): Promise<User> const [userId, setUserId] = useState<string>() // string | undefined useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId!), // 단언(!)이 없으면 'string | undefined' 타입 오류! enabled: Boolean(userId) })

반면 skipToken은 조건문의 참인 분기 안에서 쿼리 함수를 작성합니다.
그 분기 안에서는 userId가 string으로 좁혀지므로, 단언 없이 그대로 사용할 수 있습니다.

TSX
1
2
3
4
5
6
import { useQuery, skipToken } from '@tanstack/react-query' useQuery({ queryKey: ['user', userId], queryFn: userId ? () => fetchUser(userId) : skipToken // userId는 string! })
# select

선택 함수(select)를 사용하면 가져온 데이터를 변형(선택)할 수 있습니다.
쿼리 함수가 반환하는 데이터를 인수로 받아 선택 함수에서 처리하고 반환하면 최종 데이터가 됩니다.
최종 데이터의 타입은 useQuery의 3번째 제네릭 타입으로 선언할 수 있습니다.
2번째는 오류 타입(Error)입니다.

다음 예제에서 사용한 API는 사용자 정보 API입니다.

/src/components/UserNames.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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[] 타입으로 추론됩니다.

TSX
1
2
3
4
5
6
7
8
9
useQuery({ queryKey: ['users'], queryFn: async (): Promise<Users> => { const res = await fetch('https://api.heropy.dev/v0/users') const { users } = await res.json() return users }, select: data => data.map(user => user.name) })
# placeholderData

검색어처럼 쿼리 키의 일부가 바뀌면 다른 쿼리를 보게 되므로, 새로운 데이터를 받기 전까지 데이터가 없는 상태(undefined)가 되면서 화면이 깜빡일 수 있습니다.
이런 현상을 방지하기 위해 placeholderData 옵션을 사용하면, 쿼리 함수가 호출되는 대기 상태(Pending)에서 임시로 표시할 데이터를 미리 지정할 수 있습니다.
placeholderData 옵션에는 함수를 지정할 수 있으며, 이 함수는 새로운 데이터를 가져오기 직전의 이전(Previous) 데이터를 받을 수 있어서 이를 반환해 임시 데이터로 사용할 수 있습니다.

invalidateQueries()로 무효화만 한 경우에는 이전 데이터가 그대로 남아 있어서 깜빡이지 않습니다.

/src/components/Movies.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ... export default function Movies() { // ... const { data: movies } = useQuery<Movie[]>({ queryKey: ['movies', searchText], // 검색어 queryFn: async () => { const res = await fetch(`https://omdbapi.com?apikey=7035c60c&s=${searchText}`) const { Search: movies } = await res.json() return movies }, placeholderData: prev => prev }) // ... }

임시 데이터는 캐시에 저장되지 않습니다.
그리고 임시 데이터가 표시되는 동안에는 isPlaceholderData가 true, 상태는 성공(success)이고 isPending은 false이므로, 로딩 화면 분기를 만들 때 주의하세요.

# structuralSharing

structuralSharing 옵션으로, 새로운 데이터를 가져올 때 이전 데이터와 비교해 변경되지 않은 부분은 이전 데이터를 재사용하도록 지정할 수 있습니다.
이를 통해 메모리 사용량을 최적화하고 불필요한 리렌더링을 방지할 수 있습니다.

다음 예제와 같이 중첩된 객체의 이전 데이터와 새로운 데이터가 있습니다.
structuralSharing 옵션이 true이면 변경된 부분만 새롭게 업데이트하고 변경되지 않은 부분은 이전 데이터의 참조를 재사용합니다.
반대로 옵션이 false이면, 모든 객체가 새로운 참조로 생성됩니다.

TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 이전 데이터 const prevUser = { id: 'abc123', name: 'Neo', age: 22, contact: { email: 'neo@gmail.com', address: { country: 'Korea', city: 'Seoul' } } } // 새로운 데이터 const newUser = { id: 'abc123', name: 'Neo', age: 22, contact: { email: 'neo@gmail.com', address: { country: 'Korea', city: 'Suwon' // 변경된 부분! } } }

이때 재사용되는 것은 변경 경로 바깥의 가지입니다.
위 예제처럼 가장 깊은 곳의 값이 바뀌면 contact와 contact.address, 최상위 객체는 모두 새 참조가 됩니다.

때로는 structuralSharing 옵션을 false로 지정하는 것이 더 유리할 수 있습니다.
예를 들어, 매우 큰 중첩 객체를 다루는 경우 구조적인 비교 자체가 성능에 부담이 될 수 있습니다.
그리고 구조적 공유는 JSON으로 표현할 수 있는 데이터에서만 동작합니다.
Date, Map, Set, 클래스 인스턴스처럼 직렬화할 수 없는 값이 섞여 있으면, 그 값은 항상 변경된 것으로 취급됩니다.
예를 들어 위 예제의 사용자 객체에 createdAt: new Date('2026-01-01') 같은 속성이 있다면, 두 데이터가 같은 시간 값을 가지더라도 createdAt 속성은 매번 변경된 것으로 판단됩니다.

# meta

meta 속성은 쿼리에 대한 추가 정보를 제공할 수 있습니다.
지정한 정보는 쿼리 함수의 컨텍스트(queryFn: ({ meta }) => ...)와 쿼리 캐시의 콜백에서 모두 읽을 수 있습니다.

예를 들어, 쿼리 함수에서 발생한 오류 메시지 출력을 전역적으로 처리할 수 있습니다.
쿼리 클라이언트 생성의 queryCache 옵션에서 호출 쿼리의 추가 정보(meta)를 얻을 수 있습니다.

/src/main.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ... import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query' const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (_error, query) => { alert(query.meta?.myErrorMessage) // 오류 메시지 출력! } }) }) // ...
/src/components/Movies.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ... export default function Movies() { // ... const { data: movies } = useQuery<Movie[]>({ queryKey: ['movies', searchText], // 검색어 queryFn: async () => { const res = await fetch(`https://omdbapi.com?apikey=7035c60c&s=${searchText}`) const { Search: movies } = await res.json() return movies }, meta: { myErrorMessage: '영화를 검색할 수 없어요!' } }) // ... }

# 반환

다음은 알파벳 순으로 정렬한 목록입니다.

반환 속성 설명 타입
data 성공적으로 가져온 데이터.
initialData를 지정하지 않으면 첫 렌더링에서 undefined.
TData | undefined
dataUpdatedAt 최근에 데이터를 성공적으로 가져온 시간(유닉스 타임스탬프). number
error 오류가 발생했을 때의 오류 객체.
오류가 발생하지 않았다면 null.
null | TError
errorUpdateCount 모든 오류의 횟수. number
errorUpdatedAt 최근에 오류가 발생한 시간(유닉스 타임스탬프). number
failureCount 쿼리의 실패 횟수.
쿼리가 실패할 때마다 증가하고 쿼리가 성공하면 0으로 재지정.
number
failureReason 쿼리의 재시도 실패 이유.
쿼리가 성공하면 null로 재지정.
null | TError
fetchStatus 'fetching': 쿼리 함수가 실행 중.(첫 대기 및 백그라운드 다시 가져오기 포함, isFetching)
'paused': 쿼리 함수의 가져오기가 일시 중단됨.(isPaused)
'idle': 쿼리 함수가 동작 중이지 않음.
'fetching' | 'paused' | 'idle'
isEnabled 쿼리가 활성 상태인지 여부.
함수로 지정한 enabled 옵션의 계산 결과 확인에 사용.
boolean
isError 쿼리 함수에서의 오류 발생 여부. boolean
isFetched 쿼리의 첫 데이터 가져오기가 완료되었는지 여부. boolean
isFetchedAfterMount 컴포넌트 연결 후 가져오기가 완료되었는지 여부.
컴포넌트 연결 전에 캐시된 데이터를 표시하지 않는 용도로 사용.
boolean
isFetching 쿼리 함수가 실행 중.(첫 대기 및 백그라운드 다시 가져오기 포함) boolean
isLoading 쿼리 함수의 첫 번째 가져오기가 진행 중.
isFetching && isPending과 같음.
boolean
isLoadingError 쿼리 함수의 첫 번째 가져오기 중 실패 여부. boolean
isPaused 쿼리 가져오기가 일시 중단됨. boolean
isPending 캐시된 데이터가 없고 쿼리가 아직 완료되지 않은 상태. boolean
isPlaceholderData 표시된 데이터가 대체 데이터인지 여부.
이 값이 true인 분기에서는 data가 항상 존재하는 것으로 타입이 좁혀짐.
boolean
isRefetchError 쿼리가 다시 가져오기를 시도하는 중에 실패했는지 여부. boolean
isRefetching 백그라운드에서 다시 가져오기가 진행 중인지의 여부.
isFetching && !isPending과 같음.
boolean
isStale 캐시된 데이터가 무효화(Invalidated)되거나 staleTime이 경과된 여부.
enabled가 false이거나 staleTime이 'static'이면 항상 false.
boolean
isSuccess 쿼리 데이터를 성공적으로 가져왔는지 여부. boolean
refetch 데이터를 새롭게 다시 가져오는 함수.
throwOnError: true 옵션을 사용해야 오류가 발생.
(options?: { throwOnError?: boolean, cancelRefetch?: boolean }) => Promise<UseQueryResult>
status 'pending': 캐시된 데이터가 없고 아직 완료되지 않은 상태.(isPending)
'error': 오류가 발생한 상태.(isError)
'success': 데이터를 성공적으로 가져온 상태.(isSuccess)
'pending' | 'error' | 'success'
# 상태 확인

isFetching은 쿼리 함수(queryFn)가 실행 중인지의 여부로, 데이터를 가져오는 중을 나타냅니다.

isPending은 캐시된 데이터가 없고 쿼리가 아직 완료되지 않은 상태의 여부로, initialData 혹은 placeholderData 옵션으로 데이터를 제공하면 출력 대기(Pending)가 필요하지 않으므로 false를 반환합니다.
enabled 옵션을 false로 지정하면, 쿼리가 대기 상태로 시작하므로 isPending은 true를 반환합니다.
이때 isPending으로 로딩 화면을 그리면 쿼리가 활성화될 때까지 계속 로딩 상태로 남으니 주의하세요.

isLoading은 isFetching && isPending과 같은 의미로, 쿼리의 첫 번째 가져오기가 진행 중인 경우를 나타냅니다.
enabled 옵션을 사용한다면 isPending 대신 isLoading을 사용하고, 현재 활성 여부는 isEnabled로 확인하세요.

/src/components/DelayedData.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ... export default function DelayedData() { const { data, isFetching, isPending, isLoading } = useQuery<ResponseValue>({ queryKey: ['delay'], queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(), staleTime: 1000 * 10 }) return ( <> <div>isFetching: {JSON.stringify(isFetching)}</div> <div>isPending: {JSON.stringify(isPending)}</div> <div>isLoading: {JSON.stringify(isLoading)}</div> <div>{data?.time}</div> </> ) }

아래의 다시 가져오기 주제에서 설명하는 refetch 함수를 활용하면, 각 상태를 좀 더 쉽게 이해할 수 있습니다.

/src/components/DelayedData.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { useQuery } from '@tanstack/react-query' type ResponseValue = { message: string time: string } export default function DelayedData() { const { data, isStale, isFetching, isLoading, refetch } = useQuery<ResponseValue>({ queryKey: ['delay'], queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(), staleTime: 1000 * 10 }) return ( <> {isLoading ? ( <div>로딩 중..</div> ) : ( <> <div>{data?.time}</div> <div>데이터가 상했나요?: {JSON.stringify(isStale)}</div> <button disabled={isFetching} onClick={() => refetch()}> {isFetching ? '데이터 가져오는 중..' : '데이터 다시 가져오기!'} </button> </> )} </> ) }
# 다시 가져오기

refetch 함수를 사용하면, 데이터를 항상 새롭게 다시 가져올 수 있습니다.

/src/components/DelayedData.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ... 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> </> ) }

만약 신선도(staleTime) 기반으로 데이터를 가져오려면, queryClient.fetchQuery() 메소드를 사용할 수 있습니다.
주의할 부분은, fetchQuery()가 훅의 옵션을 그대로 물려받지 않는다는 점입니다.
따라서 queryKey와 staleTime를 기존 쿼리와 동일하게 제공해야 하고, staleTime을 제공하지 않으면 기본값 0이 적용돼서 캐시가 아무리 새것이어도 항상 서버에 다시 요청합니다.
그리고 queryFn은 같은 쿼리 키를 사용하는 useQuery가 화면에 연결돼 있을 때만 생략할 수 있습니다.

queryOptions 함수를 사용해 옵션을 미리 정의하고 재사용할 수 있습니다.
단순한 객체 재사용을 넘어, 반환된 queryKey에 데이터 타입과 오류 타입이 함께 각인됩니다.
그래서 queryClient.getQueryData(options.queryKey)처럼 제네릭 없이 호출해도 타입이 추론됩니다.

/src/components/DelayedData.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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) async 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.ensureQueryData() 메소드를 사용할 수 있습니다.
다만 getQueryData()와 달리 queryFn이 포함된 옵션 객체를 받고, 캐시가 있어도 항상 프로미스를 반환하는 비동기 메소드입니다.
그리고 캐시가 있으면 데이터가 상했더라도 다시 가져오지 않습니다.
상했을 때 백그라운드 갱신까지 원하면 revalidateIfStale: true 옵션을 추가하세요.

useQueryClient 훅을 사용해 queryClient 객체를 가져온 후, getQueryData 메소드를 사용합니다.

/src/components/DelayedData.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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> </> ) }

# useInfiniteQuery

'더 보기' 버튼으로 추가 데이터를 가져오거나, 더 나아가 무한 스크롤로 목록을 이어 붙이는 방식은 쉽게 찾아볼 수 있는 일반적인 UI입니다.
TanStack Query는 이런 UI 개발을 위해, useInfiniteQuery 훅을 제공합니다.

TSX
1
const 반환 = useInfiniteQuery<페이지타입>(옵션)

# 옵션

useInfiniteQuery는 앞서 살펴본 useQuery의 모든 옵션을 사용할 수 있으며, 추가로 다음의 옵션들을 사용할 수 있습니다.
다음은 알파벳 순으로 정렬한 목록입니다.

옵션 설명 기본값 타입
getNextPageParam 새 데이터를 가져올 때마다 마지막 페이지의 정보로 호출되는 함수.
필수 옵션!
다음 페이지 번호를 반환해야 함!
다음 페이지가 없으면, undefined 또는 null을 반환해야 함!
반환값이 hasNextPage를 결정!
(lastPage: TPage, allPages: TPage[], lastPageParam: TPageParam, allPageParams: TPageParam[]) => TPageParam | undefined | null
getPreviousPageParam 새 데이터를 가져올 때마다 첫 번째 페이지의 정보로 호출되는 함수.
이전 페이지 번호를 반환해야 함!
이전 페이지가 없으면, undefined 또는 null을 반환해야 함!
지정하지 않으면 hasPreviousPage는 항상 false!
(firstPage: TPage, allPages: TPage[], firstPageParam: TPageParam, allPageParams: TPageParam[]) => TPageParam | undefined | null
initialPageParam 첫 번째 페이지의 번호.
필수 옵션!
TPageParam
maxPages 저장 및 출력할 최대 페이지의 수.
undefined나 0이면 제한 없음.
0보다 크게 지정하면 getNextPageParam과 getPreviousPageParam을 모두 정의해야 함!
undefined number

# 반환

그리고 역시 useQuery의 모든 반환 속성을 사용할 수 있으며, 추가로 다음의 반환 속성들을 사용할 수 있습니다.
다음은 알파벳 순으로 정렬한 목록입니다.

반환 속성 설명 타입
fetchNextPage 다음 페이지를 가져오는 함수. (options?: FetchNextPageOptions) => Promise<UseInfiniteQueryResult>
fetchPreviousPage 이전 페이지를 가져오는 함수. (options?: FetchPreviousPageOptions) => Promise<UseInfiniteQueryResult>
hasNextPage 다음 페이지가 있는지 여부. boolean
hasPreviousPage 이전 페이지가 있는지 여부. boolean
isFetchingNextPage 다음 페이지를 가져오는 중인지의 여부. boolean
isFetchingPreviousPage 이전 페이지를 가져오는 중인지의 여부. boolean
isFetchNextPageError 다음 페이지를 가져오다가 실패했는지의 여부. boolean
isFetchPreviousPageError 이전 페이지를 가져오다가 실패했는지의 여부. boolean

# 예제

다음은 사용자가 입력한 영화 제목으로 영화 목록을 가져와 출력하고, '더 보기' 버튼을 선택해 추가 영화 목록을 가져올 수 있는 useInfiniteQuery 훅의 이해를 돕는 간단한 예제입니다.
쿼리 함수에서 반환하는 데이터는 data.pages 배열에 저장되는 것에 주의하세요.

/src/components/MovieList.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import { Fragment, useState } from 'react' import { useInfiniteQuery } from '@tanstack/react-query' export interface Page { Search: Movie[] // 검색된 영화 목록 totalResults: string // 검색된 모든 결과의 수 Response: string // 'True' or 'False' } export interface Movie { Title: string Year: string imdbID: string Type: string Poster: string } export default function MovieList() { const [searchText, setSearchText] = useState('') const [queryText, setQueryText] = useState('') const { data, // 가져온 데이터 isLoading, // 첫 페이지 가져오는 중 isFetching, // 가져오는 중(첫 페이지 포함) isFetched, // 첫 페이지 가져오기 완료 hasNextPage, // 다음 페이지가 있는지 여부 fetchNextPage // 다음 페이지 가져오기 함수 } = useInfiniteQuery<Page>({ queryKey: ['movies', queryText], // 검색어로 쿼리 키 생성! queryFn: async ({ pageParam }) => { const res = await fetch(`https://omdbapi.com/?apikey=7035c60c&s=${queryText}&page=${pageParam}`) return res.json() }, initialPageParam: 1, // 첫 페이지 번호 초기화! getNextPageParam: (lastPage, pages) => { // 한 페이지당 최대 10개까지의 영화 정보를 가져옴! // 마지막 페이지 번호 계산! const maxPage = Math.ceil(Number(pages[0].totalResults) / 10) const currentPage = pages.length // 다음 페이지가 있으면, 다음 페이지 번호 반환! if (lastPage.Response === 'True' && currentPage < maxPage) { return currentPage + 1 } // 다음 페이지가 없으면 undefined | null 반환! return null }, enabled: Boolean(queryText), // 검색어 입력 전까지 대기! staleTime: 1000 * 60 * 60 // 1시간 }) function handleSubmit(e: React.FormEvent) { e.preventDefault() // 검색! setQueryText(searchText) } return ( <> <form onSubmit={handleSubmit}> <input type="text" value={searchText} placeholder="영화 제목을 입력하세요." onChange={e => setSearchText(e.target.value)} /> <button type="submit">검색</button> </form> <div> {data?.pages.map((page, index) => ( // 각 페이지의 출력 최적화를 위해, 페이지 단위 key 속성을 추가! <Fragment key={index}> {page.Search && page.Search.map(movie => ( <div key={movie.imdbID}>{movie.Title}</div> ))} </Fragment> ))} </div> {isLoading ? <div>로딩 중..</div> : null} {isFetched && hasNextPage && ( <button disabled={isFetching} onClick={() => fetchNextPage()}> {isFetching ? '로딩 중..' : '더 보기!'} </button> )} </> ) }

isFetching은 첫 페이지를 포함한 모든 가져오기에서 true가 됩니다.
'더 보기'처럼 다음 페이지를 가져오는 중만 구분하려면 isFetchingNextPage를 사용하세요.

위 예제를 무한 스크롤 기능으로 구현하면, 다음과 같이 수정할 수 있습니다.

TanStack Query 개발자 도구는 별도 창이 아닌 단순히 화면 안의 요소이기 때문에, 무한 스크롤을 화면에서 테스트할 때는 일시적으로 개발자 도구를 닫는 것을 추천합니다.
여기서 말하는 개발자 도구는 브라우저의 개발자 도구가 아닙니다!

/src/components/MovieList.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import { Fragment, useState, useEffect, useRef } from 'react' import { useInfiniteQuery } from '@tanstack/react-query' // ... export default function MovieList() { const [searchText, setSearchText] = useState('') const [queryText, setQueryText] = useState('') const observerEl = useRef<HTMLDivElement | null>(null) const { data, // isLoading, isFetching, // isFetched, hasNextPage, fetchNextPage } = useInfiniteQuery<Page>({ // ... }) useEffect(() => { const currentObserverEl = observerEl.current const io = new IntersectionObserver(entries => { if (entries[0].isIntersecting) { fetchNextPage() } }, { rootMargin: '0px 0px 500px 0px' }) if (currentObserverEl) { io.observe(currentObserverEl) } return () => { if (currentObserverEl) { io.disconnect() } } }, [fetchNextPage]) // ... return ( <> {/* ... */} {/* {isLoading ? <div>로딩 중..</div> : null} {isFetched && hasNextPage && ( <button disabled={isFetching} onClick={() => fetchNextPage()}> {isFetching ? '로딩 중..' : '더 보기!'} </button> )} */} {isFetching && <div>로딩 중..</div>} <div ref={observerEl} style={{ display: isFetching || !hasNextPage ? 'none' : 'block', height: '20px' }} /> </> ) }

좀 더 간결한 코드를 원한다면, react-intersection-observer 라이브러리를 사용할 수 있습니다.
ref 속성을 관찰 요소와 연결하고, inView 속성을 사용해 요소가 화면에 보이는지 여부를 확인할 수 있습니다.

라이브러리를 설치하고 아래 예제와 같이 수정해 보세요.

BASH
1
npm i react-intersection-observer
/src/components/MovieList.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { useState, useEffect } from 'react' import { useInfiniteQuery } from '@tanstack/react-query' import { useInView } from 'react-intersection-observer' // ... export default function MovieList() { const [searchText, setSearchText] = useState('') const [queryText, setQueryText] = useState('') const { ref, inView } = useInView({ rootMargin: '0px 0px 500px 0px' }) // ... // useEffect(() => { // const currentObserverEl = observerEl.current // const io = new IntersectionObserver(entries => { // if (entries[0].isIntersecting && hasNextPage) { // fetchNextPage() // } // }) // if (currentObserverEl) { // io.observe(currentObserverEl) // } // return () => { // if (currentObserverEl) { // io.disconnect() // } // } // }, [hasNextPage, fetchNextPage]) useEffect(() => { if (inView) { fetchNextPage() } }, [inView, fetchNextPage]) // ... return ( <> {/* ... */} {isFetching && <div>로딩 중..</div>} <div ref={ref} style={{ display: isFetching || !hasNextPage ? 'none' : 'block', height: '20px' }} /> </> ) }

영화 정보 객체만 포함된 배열을 만들기 위해서 다음과 같이 select 옵션을 사용할 수 있습니다.

TS
1
2
3
4
5
6
7
import { infiniteQueryOptions } from '@tanstack/react-query' infiniteQueryOptions({ select: data => data.pages.flatMap(page => { return page.Response === 'True' ? page.Search : [] }) })

# useMutation

TanStack Query는 데이터 변경 작업(생성, 수정, 삭제 등)을 위한 useMutation 훅을 제공합니다.
이를 통해, 데이터 변경 작업을 처리하고 다양한 성공, 실패, 로딩 등의 상태를 얻을 수 있습니다.
그리고 요청 실패 시의 자동 재시도나 낙관적 업데이트 같은 고급 기능도 쉽게 처리할 수 있습니다.

쿼리(useQuery)는 '가져오기'에 집중하는 반면, 변이(useMutation)는 '보내기'에 집중하는 훅으로 이해하면 쉽습니다.

낙관적 업데이트(Optimistic Update)는 서버 요청의 응답을 기다리지 않고, 먼저 UI를 업데이트하는 기능을 말합니다.
서버 응답이 느린 상황에서도 빠른 인터페이스를 제공할 수 있어 사용자 경험을 크게 향상시킬 수 있습니다.

TSX
1
const 반환 = useMutation(옵션)

# 옵션

useMutation 훅은 다음과 같은 옵션을 제공합니다.
다음은 알파벳 순으로 정렬한 목록입니다.

옵션 설명 기본값 타입
gcTime 비활성 캐시 데이터(Inactive)가 메모리에 남아 있는 시간(ms).
서버 환경에서는 Infinity.
5 * 60 * 1000 number | Infinity
meta 활용할 추가 정보를 지정. Record<string, unknown>
mutationFn 실행할 비동기 변이 함수.
필수 옵션!
(variables: TVariables, context: MutationFunctionContext) => Promise<TData>
mutationKey queryClient.setMutationDefaults의 기본값 상속을 위한 키 readonly unknown[]
networkMode 네트워크 모드 지정. 'online' 'online' | 'always' | 'offlineFirst'
onError 변이 중 오류가 발생할 때 호출되는 함수. (error: TError, variables: TVariables, onMutateResult: TOnMutateResult | undefined, context: MutationFunctionContext) => Promise<unknown> | unknown
onMutate 변이 함수가 실행되기 전에 호출되는 함수.
반환값은 이후 콜백에 onMutateResult 인수로 전달!
(variables: TVariables, context: MutationFunctionContext) => Promise<TOnMutateResult> | TOnMutateResult
onSettled 변이가 성공하거나 실패해도 항상 호출되는 함수. (data: TData | undefined, error: TError | null, variables: TVariables, onMutateResult: TOnMutateResult | undefined, context: MutationFunctionContext) => Promise<unknown> | unknown
onSuccess 변이가 성공할 때 호출되는 함수. (data: TData, variables: TVariables, onMutateResult: TOnMutateResult, context: MutationFunctionContext) => Promise<unknown> | unknown
retry 변이 실패 시 재시도 횟수. 0 boolean | number | (failureCount: number, error: TError) => boolean
retryDelay 재시도 시간 간격(ms). Math.min(1000 * 2 ** 실패횟수, 30000) number | (retryAttempt: number, error: TError) => number
scope 동시 실행 범위 지정.
같은 범위 ID를 가진 변이는 병렬이 아닌 직렬로 실행.
{ id: string }
throwOnError 변이 실패 시 오류를 던질지 여부. undefined undefined | boolean | (error: TError) => boolean

queryClient는 옵션 객체의 속성이 아니라 useMutation의 두 번째 인수입니다.
<QueryClientProvider>의 쿼리 클라이언트 대신 다른 클라이언트를 사용할 때만 지정합니다.

5.89.0부터 변이 콜백의 시그니처가 바뀌었습니다.
onMutate가 반환한 값은 예전에 context라는 이름으로 전달됐지만, 지금은 onMutateResult라는 이름으로 전달됩니다.
그리고 모든 콜백의 마지막 인수로 { client, meta, mutationKey } 형태의 MutationFunctionContext가 추가됐습니다.

# 반환

다음은 알파벳 순으로 정렬한 목록입니다.

반환 속성 설명 타입
context onMutate가 반환한 값.
낙관적 업데이트 되돌리기에 활용.
undefined | TOnMutateResult
data 성공적으로 가져온 데이터. undefined | TData
error 오류가 발생했을 때의 오류 객체.
오류가 발생하지 않았다면 null.
null | TError
failureCount 변이의 실패 횟수.
변이가 실패할 때마다 증가하고 변이가 성공하면 0으로 재지정.
number
failureReason 변이의 재시도 실패 이유.
변이가 성공하거나 새 변이가 시작되면 null로 재지정.
null | TError
isError 변이 함수에서의 오류 발생 여부. boolean
isIdle 변이 함수가 실행되기 전의 초기 상태인지 여부 boolean
isPaused 변이 함수가 일시 중단되었는지 여부 boolean
isPending 변이 함수가 실행 중인지 여부 boolean
isSuccess 데이터를 성공적으로 가져왔는지 여부. boolean
mutate 변이 실행 함수 (variables: TVariables, options?: MutateOptions) => void
mutateAsync 비동기 변이 실행 함수 (variables: TVariables, options?: MutateOptions) => Promise<TData>
reset 변이 내부 상태를 초기 상태로 재지정하는 함수 () => void
status 변이의 현재 상태.
idle: 초기 상태
pending: 실행 중
error: 오류 발생
success: 성공
'idle' | 'pending' | 'error' | 'success'
submittedAt 변이가 제출된 시간(유닉스 타임스탬프). number
variables 변이 실행 함수(mutate)에 전달된 데이터. undefined | TVariables

mutate의 두 번째 인수로 넘기는 onSuccess, onError, onSettled는 useMutation 옵션의 같은 이름 콜백과 다르게 동작합니다.
반환값이 무시돼서 프로미스를 반환해도 기다려 주지 않고, 컴포넌트가 연결 해제되면 아예 실행되지 않습니다.
또 mutate를 연달아 호출하면 마지막 호출에 넘긴 콜백만 한 번 실행되는 반면, 옵션 콜백은 매 호출마다 실행됩니다.

# 예제

우선 다음과 같이, 사용자 목록을 가져오는 쿼리(['users'])가 있습니다.

/src/components/Users.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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분 }) // ... }

변이는 조회(Read)를 제외한 데이터 생성(Create)이나 수정(Update), 삭제(Delete) 작업을 수행합니다.
다음 예제는 입력한 사용자의 이름과 나이로 새로운 사용자를 추가하는 예제입니다.

/src/components/AddUser.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
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, onMutateResult) => { console.log('onSuccess', data, newUser, onMutateResult) // 변이 성공 시 캐시 무효화로 사용자 목록 데이터 갱신! queryClient.invalidateQueries({ queryKey: ['users'] }) }, onError: (error, newUser, onMutateResult) => { console.log('onError', error, newUser, onMutateResult) // 변이 실패 시, 낙관적 업데이트 결과를 이전 사용자 목록으로 되돌리기! if (onMutateResult) { queryClient.setQueryData(['users'], onMutateResult.previousUsers) } }, onSettled: (data, error, newUser, onMutateResult) => { console.log('onSettled', data, error, newUser, onMutateResult) }, 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> ) }

# 개발자 도구 사용

TanStack Query 전용 개발자 도구를 활용할 수 있습니다.
이를 통해 쿼리의 상태나 데이터, 오류, 캐시 등을 쉽게 확인하고 디버깅할 수 있습니다.
우선 다음과 같이 개발자 도구 라이브러리를 설치합니다.

BASH
1
npm i @tanstack/react-query-devtools

브라우저 개발자 도구 안에서 사용하고 싶다면, Chrome/Firefox/Edge용 서드파티 확장 프로그램을 설치하는 방법도 있습니다.
패키지를 설치해 화면에 넣는 방식과 기능은 같지만, 화면을 가리지 않아서 무한 스크롤처럼 화면 전체를 사용하는 테스트에 편합니다.

쿼리 공급자(<QueryClientProvider>) 범위에서 개발자 도구 컴포넌트를 사용하면 됩니다.

/src/App.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { QueryClient, QueryClientProvider, } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import DelayedData from './components/DelayedData' const queryClient = new QueryClient() export default function App() { return ( <QueryClientProvider client={queryClient}> <DelayedData /> <ReactQueryDevtools /> </QueryClientProvider> ) }

이제 개발 서버를 실행하고 브라우저를 확인하면, 다음 이미지와 같이 화면 우측 하단에 TanStack 로고 모양의 개발자 도구 버튼이 나타납니다.

TanStack Query

버튼을 누르고 개발자 도구를 열어 사용하거나 도구 우측 상단의 버튼을 선택해 닫을 수도 있습니다.

TanStack Query 개발자 도구

# with Next.js

Next.js App Router에서는 쿼리 공급자를 클라이언트 컴포넌트로 분리하고, 서버 컴포넌트에서 데이터를 미리 가져와 클라이언트로 넘기는 구성을 사용합니다.
서버가 데이터를 채운 상태로 화면을 내려주기 때문에, 첫 화면부터 데이터를 볼 수 있습니다.

# 쿼리 공급자

<QueryClientProvider>는 내부적으로 컨텍스트를 사용하므로, 'use client'를 선언한 파일로 분리합니다.
그리고 쿼리 클라이언트는 서버와 브라우저에서 다르게 만들어야 합니다.
서버에서는 요청마다 새로 만들어야 요청 사이에 데이터가 섞이지 않습니다.
반대로 브라우저에서는 하나를 재사용해야, 첫 렌더링 도중에 컴포넌트가 대기하더라도 쿼리 클라이언트가 다시 만들어지지 않습니다.

/providers/query.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
'use client' import { QueryClient, QueryClientProvider, environmentManager } from '@tanstack/react-query' function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { // 클라이언트에서 곧바로 다시 요청하지 않도록, 기본 신선도를 지정 staleTime: 60 * 1000 } } }) } let browserQueryClient: QueryClient | undefined = undefined function getQueryClient() { if (environmentManager.isServer()) { return makeQueryClient() // 서버는 요청마다 새로 생성! } if (!browserQueryClient) browserQueryClient = makeQueryClient() return browserQueryClient // 브라우저는 하나를 재사용! } export function QueryProvider({ children }: { children: React.ReactNode }) { const queryClient = getQueryClient() return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> }

5.91.0부터 isServer 상수는 폐기됐습니다.
아직 동작하지만 environmentManager.isServer()를 사용하세요.

그리고 루트 레이아웃에서 구성한 <QueryProvider>를 사용합니다.

/app/layout.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { QueryProvider } from '@/providers/query' export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( <html lang="ko"> <body> <QueryProvider>{children}</QueryProvider> </body> </html> ) }

# 서버에서 미리 가져오기

여기까지 구성하면 클라이언트 컴포넌트에서 useQuery를 사용할 수 있습니다.
다만 데이터를 브라우저에서 가져오기 시작하므로, 서버가 내려주는 첫 화면은 비어 있습니다.
서버 컴포넌트에서 데이터를 미리 가져오고(Prefetch), 그 결과를 <HydrationBoundary>로 클라이언트 캐시에 이어 붙이면 첫 화면부터 데이터를 채워서 보낼 수 있습니다.

서버와 클라이언트가 같은 쿼리를 가리켜야 하므로, 옵션은 queryOptions 함수로 한곳에 정의해 공유합니다.
쿼리 키나 쿼리 함수가 어긋나면 미리 가져온 데이터를 사용하지 못하고 클라이언트에서 다시 요청하게 됩니다.

/queries/users.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { queryOptions } from '@tanstack/react-query' export interface User { id: string name: string age: number } export const usersQuery = queryOptions({ queryKey: ['users'], queryFn: async (): Promise<User[]> => { const res = await fetch('https://api.heropy.dev/v0/users') const { users } = await res.json() return users } })

서버 컴포넌트에서는 new QueryClient()로 쿼리 클라이언트를 직접 만들고, 데이터를 받은 뒤 <HydrationBoundary>로 감쌉니다.
여기서 만든 쿼리 클라이언트는 이 요청의 렌더링에만 사용하고 버리기 때문에, 앞에서 만든 getQueryClient 함수를 사용하지 않습니다.

/app/users/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { QueryClient, dehydrate, HydrationBoundary } from '@tanstack/react-query' import { usersQuery } from '@/queries/users' import Users from '@/components/Users' export default async function UsersPage() { const queryClient = new QueryClient() // 데이터를 다 받은 후에 렌더링! await queryClient.prefetchQuery(usersQuery) return ( <HydrationBoundary state={dehydrate(queryClient)}> <Users /> </HydrationBoundary> ) }

미리 가져올 때는 쿼리 함수의 fetch가 서버에서 실행됩니다.
Next.js 14버전까지 fetch 함수는 기본적으로 캐싱 기능이 활성화되어 있으므로, cache: 'no-store' 옵션을 사용해야 캐싱 기능을 비활성화할 수 있습니다.
Next.js 15버전부터는 기본적으로 캐싱하지 않으므로(16버전도 동일), 해당 옵션을 사용하지 않아도 됩니다.

클라이언트 컴포넌트에서는 같은 옵션으로 useQuery를 사용합니다.
데이터가 이미 캐시에 들어와 있어서, 대기 없이 곧바로 출력됩니다.

/components/Users.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
'use client' import { useQuery } from '@tanstack/react-query' import { usersQuery } from '@/queries/users' export default function Users() { const { data } = useQuery(usersQuery) return ( <ul> {data?.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> ) }

미리 가져온 데이터가 있어도 useQuery의 data 타입은 undefined를 포함하므로, 출력할 때는 옵셔널 체이닝(data?.)이 필요합니다.

여기서 useSuspenseQuery 대신 useQuery를 사용하는 이유가 있습니다.
나중에 미리 가져오기를 지우거나 빠뜨려도, useQuery는 클라이언트에서 데이터를 가져오는 정도로 끝납니다.

Next.js의 서버 액션(Server Action)을 queryFn으로 사용하지 마세요.
클라이언트에서 호출한 서버 액션은 병렬이 아닌 직렬로 실행돼서, 쿼리가 대기 상태에서 멈추거나 아예 실행되지 않을 수 있습니다.
데이터 조회는 API 경로나 tRPC 같은 방식을 사용하고, 서버 액션은 변이(useMutation)에 사용하세요.

# 미리 가져오기 없이 처리하기

라우트마다 미리 가져오기를 작성하는 게 번거롭다면, 스트리밍으로 처리하는 방법도 있습니다.

BASH
1
npm i @tanstack/react-query-next-experimental

앞에서 만든 쿼리 공급자에서 자식을 <ReactQueryStreamedHydration>으로 감싸기만 하면 됩니다.

/providers/query.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
// ... import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental' export function QueryProvider({ children }: { children: React.ReactNode }) { const queryClient = getQueryClient() return ( <QueryClientProvider client={queryClient}> <ReactQueryStreamedHydration>{children}</ReactQueryStreamedHydration> </QueryClientProvider> ) }

이제 서버 컴포넌트에서 미리 가져오지 않아도, 클라이언트 컴포넌트의 useSuspenseQuery가 서버 렌더링 단계에서 데이터를 가져옵니다.
앞에서 만든 Users 컴포넌트와 페이지를 다음과 같이 바꿉니다.

/components/Users.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
'use client' import { useSuspenseQuery } from '@tanstack/react-query' import { usersQuery } from '@/queries/users' export default function Users() { const { data } = useSuspenseQuery(usersQuery) return ( <ul> {data.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> ) }
/app/users/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
import { Suspense } from 'react' import Users from '@/components/Users' export default function UsersPage() { return ( <Suspense fallback={<div>로딩 중..</div>}> <Users /> </Suspense> ) }