TanStack Query(React Query) 핵심 정리
# 개요
TanStack Query는 서버로부터 데이터 가져오기, 데이터 캐싱, 캐시 제어 등 데이터를 쉽고 효율적으로 관리할 수 있는 라이브러리입니다.
React Query라는 이름으로 시작했지만, v4부터 Vue나 Svelte 등의 다른 프레임워크에서도 활용할 수 있도록 기능이 확장되며 TanStack Query라는 이름으로 변경되었습니다.
대표적인 기능은 다음과 같습니다.
- 데이터 가져오기 및 캐싱
- 동일 요청의 중복 제거
- 신선한 데이터 유지
- 무한 스크롤, 페이지네이션 등의 성능 최적화
- 네트워크 재연결, 요청 실패 등의 자동 갱신
# 데이터 캐싱
TanStack Query를 활용해서 데이터를 가져올 때는 항상 쿼리 키(queryKey)를 지정하게 됩니다.
이 쿼리 키는 캐시된 데이터와 비교해 새로운 데이터를 가져올지, 캐시된 데이터를 사용할지 결정하는 기준이 됩니다.
123456789import { 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> }
다음 이미지는 쿼리 키와 일치하는 캐시된 데이터가 없을 때, 서버에서 새로운 데이터를 가져오는 과정을 보여줍니다.
서버에서 데이터를 가져오면 그 데이터는 캐시되고 그 이후 요청부터는 캐시된 데이터를 사용할 수 있습니다.
반대로 쿼리 키와 일치하는 캐시된 데이터가 있으면, 서버에 요청하지 않고 캐시된 데이터를 사용하게 됩니다.
따라서 같은 데이터를 가져오는 요청이 여러 번 발생해도, 캐시된 데이터를 사용하게 되어 중복 요청을 줄일 수 있습니다.
다만 기본 옵션으로는 캐시된 데이터를 곧바로 상한 것으로 취급하기 때문에, 화면에는 캐시된 데이터를 보여주면서 뒤에서는 새로운 데이터를 다시 가져옵니다.
그렇다면 캐시된 데이터를 언제까지 사용할지는 어떻게 정할 수 있을까요?
# 데이터의 신선도
TanStack Query는 캐시한 데이터를 신선(Fresh)하거나 상한(Stale) 상태로 구분해 관리합니다.
캐시된 데이터가 신선하다면 서버에 요청하지 않고 캐시된 데이터를 그대로 사용합니다.
반면 데이터가 상하면 다시 가져올 수 있는 상태가 되고, 이때 컴포넌트가 새로 연결되거나 브라우저 화면이 다시 포커스되거나 네트워크가 재연결되면 서버에 다시 요청해 새로운 데이터를 가져옵니다.
데이터가 상했다고 해서 그 즉시 요청을 보내지는 않습니다.
데이터의 신선도는 일종의 유통기한으로 생각하면 이해하기 쉽습니다.
데이터가 상하는 데까지 걸리는 시간은 staleTime 옵션으로 지정할 수 있습니다.
그리고 신선한지 상했는지 여부는 isStale로 확인할 수 있습니다.
123456789101112131415import { 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> </> ) }
한 번 받으면 바뀌지 않는 데이터라면, staleTime에 시간 대신 'static'을 지정할 수 있습니다.'static'인 데이터는 절대 상하지 않고, 컴포넌트 연결이나 화면 포커스 같은 자동 갱신 조건에서도 다시 가져오지 않습니다.Infinity를 지정하면 queryClient.invalidateQueries()로 무효화했을 때는 다시 가져오지만, 'static'은 무효화해도 다시 가져오지 않습니다.
12345678export 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> }
# 설치 및 구성
다음과 같이 TanStack Query를 설치합니다.
TanStack Query ESLint 플러그인을 사용할 수도 있습니다.
12npm i @tanstack/react-query npm i -D @tanstack/eslint-plugin-query
ESLint 플러그인의 권장 규칙을 사용하면, 일반적인 실수를 피하는 데 도움이 됩니다.
Flat Config(eslint.config.js)에서는 configs['flat/recommended']를 전개해 추가합니다.
이 값은 설정 객체의 배열이므로 전개 연산자(...)가 필요합니다.
12345import tanstackQuery from '@tanstack/eslint-plugin-query' export default [ ...tanstackQuery.configs['flat/recommended'] ]
권장 규칙(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 인스턴스를 연결합니다.
이제 사용할 준비가 끝났습니다!
123456789101112131415import { 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
가장 기본적인 쿼리 훅으로, 컴포넌트에서 데이터를 가져올 때 사용합니다.
1const 반환 = useQuery<데이터타입>(옵션)
다음 예제의 지연 응답 API는 t 파라미터 값의 시간이 지난 후 응답합니다.
응답 데이터는 간단한 메시지(message)와 응답 시간(time)을 포함합니다.
123456789101112131415import { 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>의 클라이언트를 사용합니다.
1const 반환 = useQuery(옵션, 쿼리클라이언트)
# queryKey
쿼리 키(queryKey)는 쿼리를 식별하는 고유한 값으로, 배열 형태로 지정합니다.
다중 아이템 쿼리 키를 사용할 때는, 아이템의 순서가 중요합니다.
123456789101112131415// 단일 아이템 쿼리 키 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의 값이 다르면, 각각 별개의 요청을 전송합니다.
123456789101112131415import { 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> }
1234567891011121314import { 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 규칙을 비활성화할 수 있습니다.
12345678910// ... 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> }
프로젝트에서 특정 규칙을 비활성화할 수도 있습니다.
12345678910import tanstackQuery from '@tanstack/eslint-plugin-query' export default [ ...tanstackQuery.configs['flat/recommended'], { rules: { '@tanstack/query/exhaustive-deps': 'off' } } ]
규칙을 통째로 끄는 대신, 특정 변수만 예외로 지정할 수도 있습니다.allowlist.variables에 변수 이름을, allowlist.types에 타입 이름을 지정하면 쿼리 키에 없어도 오류로 잡지 않습니다.
12345678910import 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: 요청 취소에 사용하는AbortSignalclient: 쿼리 클라이언트 인스턴스meta:meta옵션으로 지정한 추가 정보pageParam: 페이지 번호(useInfiniteQuery에서만)
signal을 fetch 함수에 전달하면, 쿼리가 무효화되거나 컴포넌트가 연결 해제될 때 요청이 자동으로 취소됩니다.
1234useQuery({ queryKey: ['users'], queryFn: async ({ signal }) => (await fetch('https://api.heropy.dev/v0/users', { signal })).json() })
Axios를 사용한다면 요청 구성 객체의 signal 옵션으로 전달합니다.fetch 함수와 같은 표준 AbortSignal을 사용하므로, 옵션 이름과 동작이 동일합니다.
12345useQuery({ queryKey: ['users'], queryFn: async ({ signal }) => (await axios.get('https://api.heropy.dev/v0/users', { signal })).data })
12345678910111213141516171819202122232425262728import { 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!처럼 값이 있다고 단언해야 합니다.
12345678// 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으로 좁혀지므로, 단언 없이 그대로 사용할 수 있습니다.
123456import { 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입니다.
123456789101112131415161718192021222324252627import { 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[] 타입으로 추론됩니다.
123456789useQuery({ 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) 데이터를 받을 수 있어서 이를 반환해 임시 데이터로 사용할 수 있습니다.
1234567891011121314151617// ... 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이면, 모든 객체가 새로운 참조로 생성됩니다.
123456789101112131415161718192021222324252627// 이전 데이터 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)를 얻을 수 있습니다.
12345678910111213141516// ... import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query' const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (_error, query) => { alert(query.meta?.myErrorMessage) // 오류 메시지 출력! } }) }) // ...
12345678910111213141516171819// ... 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로 확인하세요.
1234567891011121314151617// ... 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 함수를 활용하면, 각 상태를 좀 더 쉽게 이해할 수 있습니다.
123456789101112131415161718192021222324252627282930313233import { 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 함수를 사용하면, 데이터를 항상 새롭게 다시 가져올 수 있습니다.
12345678910111213141516// ... 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가 화면에 연결돼 있을 때만 생략할 수 있습니다.
12345678910111213141516171819202122232425import { 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를 반환합니다.
useQueryClient 훅을 사용해 queryClient 객체를 가져온 후, getQueryData 메소드를 사용합니다.
1234567891011121314151617181920212223import { 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 훅을 제공합니다.
1const 반환 = 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 배열에 저장되는 것에 주의하세요.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990import { 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를 사용하세요.
위 예제를 무한 스크롤 기능으로 구현하면, 다음과 같이 수정할 수 있습니다.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364import { 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 속성을 사용해 요소가 화면에 보이는지 여부를 확인할 수 있습니다.
라이브러리를 설치하고 아래 예제와 같이 수정해 보세요.
1npm i react-intersection-observer
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354import { 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 옵션을 사용할 수 있습니다.
1234567import { infiniteQueryOptions } from '@tanstack/react-query' infiniteQueryOptions({ select: data => data.pages.flatMap(page => { return page.Response === 'True' ? page.Search : [] }) })
# useMutation
TanStack Query는 데이터 변경 작업(생성, 수정, 삭제 등)을 위한 useMutation 훅을 제공합니다.
이를 통해, 데이터 변경 작업을 처리하고 다양한 성공, 실패, 로딩 등의 상태를 얻을 수 있습니다.
그리고 요청 실패 시의 자동 재시도나 낙관적 업데이트 같은 고급 기능도 쉽게 처리할 수 있습니다.
쿼리(useQuery)는 '가져오기'에 집중하는 반면, 변이(useMutation)는 '보내기'에 집중하는 훅으로 이해하면 쉽습니다.
1const 반환 = 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>의 쿼리 클라이언트 대신 다른 클라이언트를 사용할 때만 지정합니다.
# 반환
다음은 알파벳 순으로 정렬한 목록입니다.
| 반환 속성 | 설명 | 타입 |
|---|---|---|
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 |
# 예제
우선 다음과 같이, 사용자 목록을 가져오는 쿼리(['users'])가 있습니다.
12345678910111213141516171819202122232425262728import { 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) 작업을 수행합니다.
다음 예제는 입력한 사용자의 이름과 나이로 새로운 사용자를 추가하는 예제입니다.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980import 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 전용 개발자 도구를 활용할 수 있습니다.
이를 통해 쿼리의 상태나 데이터, 오류, 캐시 등을 쉽게 확인하고 디버깅할 수 있습니다.
우선 다음과 같이 개발자 도구 라이브러리를 설치합니다.
1npm i @tanstack/react-query-devtools
쿼리 공급자(<QueryClientProvider>) 범위에서 개발자 도구 컴포넌트를 사용하면 됩니다.
1234567891011121314151617import { 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 로고 모양의 개발자 도구 버튼이 나타납니다.
버튼을 누르고 개발자 도구를 열어 사용하거나 도구 우측 상단의 버튼을 선택해 닫을 수도 있습니다.
# with Next.js
Next.js App Router에서는 쿼리 공급자를 클라이언트 컴포넌트로 분리하고, 서버 컴포넌트에서 데이터를 미리 가져와 클라이언트로 넘기는 구성을 사용합니다.
서버가 데이터를 채운 상태로 화면을 내려주기 때문에, 첫 화면부터 데이터를 볼 수 있습니다.
# 쿼리 공급자
<QueryClientProvider>는 내부적으로 컨텍스트를 사용하므로, 'use client'를 선언한 파일로 분리합니다.
그리고 쿼리 클라이언트는 서버와 브라우저에서 다르게 만들어야 합니다.
서버에서는 요청마다 새로 만들어야 요청 사이에 데이터가 섞이지 않습니다.
반대로 브라우저에서는 하나를 재사용해야, 첫 렌더링 도중에 컴포넌트가 대기하더라도 쿼리 클라이언트가 다시 만들어지지 않습니다.
1234567891011121314151617181920212223242526272829303132'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> }
그리고 루트 레이아웃에서 구성한 <QueryProvider>를 사용합니다.
123456789101112131415import { 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 함수로 한곳에 정의해 공유합니다.
쿼리 키나 쿼리 함수가 어긋나면 미리 가져온 데이터를 사용하지 못하고 클라이언트에서 다시 요청하게 됩니다.
12345678910111213141516import { 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 함수를 사용하지 않습니다.
12345678910111213141516import { 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> ) }
클라이언트 컴포넌트에서는 같은 옵션으로 useQuery를 사용합니다.
데이터가 이미 캐시에 들어와 있어서, 대기 없이 곧바로 출력됩니다.
1234567891011121314'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?.)이 필요합니다.
# 미리 가져오기 없이 처리하기
라우트마다 미리 가져오기를 작성하는 게 번거롭다면, 스트리밍으로 처리하는 방법도 있습니다.
1npm i @tanstack/react-query-next-experimental
앞에서 만든 쿼리 공급자에서 자식을 <ReactQueryStreamedHydration>으로 감싸기만 하면 됩니다.
1234567891011// ... 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 컴포넌트와 페이지를 다음과 같이 바꿉니다.
1234567891011121314'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> ) }
12345678910import { Suspense } from 'react' import Users from '@/components/Users' export default function UsersPage() { return ( <Suspense fallback={<div>로딩 중..</div>}> <Users /> </Suspense> ) }
끝까지 읽어주셔서 감사합니다.
좋아요와 응원 댓글은 블로그 운영에 큰 힘이 됩니다!