무엇
useQuery는 서버 상태를 읽어 오는 훅이다. 쿼리 키와 데이터를 가져오는 비동기 함수를 주면, 캐싱·로딩·에러·백그라운드 리페치를 알아서 처리하고 그 결과를 돌려준다.
인자는 객체 하나다. v5에서는 위치 인자 형태(useQuery(key, fn))가 사라졌고, 객체 형태만 받는다.
const { data, error, status } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
queryKey는 이 데이터의 신원이고, queryFn은 데이터를 resolve하거나 에러를 throw하는 프로미스를 반환해야 한다. resolve된 값은 undefined이면 안 된다.
무슨 일이 일어나나
캐시는 queryKey로 식별한다. 키를 안정적인 해시로 바꿔 같은 키끼리 캐시를 공유하고, 키가 바뀌면 자동으로 다시 가져온다. 그래서 키에는 쿼리에 영향을 주는 값(예: 검색어, id)을 전부 넣는다.
상태는 두 축으로 나뉜다. status는 데이터가 있느냐를 말하고, fetchStatus는 지금 네트워크가 도느냐를 말한다.
status:pending(아직 캐시된 데이터 없음) ·error(실패) ·success(성공). 편의 불리언isPending·isError·isSuccess가 이 값에서 파생된다.fetchStatus:fetching(queryFn 실행 중) ·paused(오프라인 등으로 멈춤) ·idle(안 함).isFetching이 여기서 파생된다.
두 축이 나뉜 이유는, 이미 데이터가 있는 상태(success)에서도 백그라운드 리페치가 돌 수 있기 때문이다(success + fetching). 첫 로딩만 가리키고 싶으면 isPending(데이터 없음)을, 첫 fetch가 도는 중을 가리키고 싶으면 isLoading(= isPending && isFetching)을 쓴다.
데이터는 가져온 뒤 staleTime(기본 0) 동안 신선한 것으로 본다. 신선하면 리페치를 건너뛰고, 오래되면(stale) 마운트·창 포커스·재연결 시 다시 가져온다. 화면에서 안 쓰이게 된 캐시는 gcTime(기본 5 * 60 * 1000, 5분) 뒤 메모리에서 수거된다. v4의 cacheTime이 v5에서 gcTime으로 바뀐 이름이다.
사용법
기본형은 키와 함수, 그리고 세 상태 분기다. pending·error를 먼저 걸러내면 그 아래에서 data는 확정 타입이 된다.
import { useQuery } from '@tanstack/react-query'
async function fetchTodos(): Promise<Todo[]> {
const res = await fetch('/api/todos')
if (!res.ok) throw new Error('불러오기 실패')
return res.json()
}
export function Todos() {
const { data, isPending, isError, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
if (isPending) return <p>로딩 중</p>
if (isError) return <p>{error.message}</p>
// 여기부터 data는 Todo[]로 좁혀진다
return <ul>{data.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
}
키에 변수를 넣으면 그 변수가 바뀔 때 자동으로 다시 가져온다. 변수가 준비되기 전에는 enabled로 실행을 막는다.
export function TodoDetail({ id }: { id?: string }) {
const { data } = useQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id!),
enabled: id != null, // id 없으면 fetch 안 함
})
return <pre>{JSON.stringify(data, null, 2)}</pre>
}
select로 필요한 부분만 골라 쓴다. 반환되는 data만 바뀌고 캐시에 저장되는 원본은 그대로다. staleTime으로 불필요한 리페치를 줄인다.
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos) => todos.length, // 개수만 필요할 때
staleTime: 60_000, // 1분간은 신선한 것으로 취급
})
실무 예시
검색어에 따라 목록을 가져오는 화면이다. 검색어를 키에 넣으면 입력이 바뀔 때 자동으로 다시 가져오고, 이전 결과를 placeholderData로 유지해 화면이 깜빡이지 않게 한다.
import { keepPreviousData, useQuery } from '@tanstack/react-query'
export function SearchResults({ q }: { q: string }) {
const { data, isPlaceholderData } = useQuery({
queryKey: ['search', q],
queryFn: () => searchApi(q),
enabled: q.length > 0,
placeholderData: keepPreviousData, // 새 q 로딩 중 이전 결과 유지
staleTime: 30_000,
})
return (
<ul style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
{data?.map((r) => <li key={r.id}>{r.name}</li>)}
</ul>
)
}
왜 중요한가
서버 상태는 클라이언트 상태와 성질이 다르다. 내 것이 아니고, 언제든 바뀌고, 여러 화면이 같은 데이터를 본다. useQuery는 이 문제(캐싱·중복 제거·리페치·무효화)를 한 훅으로 흡수해, 직접 짜던 로딩 플래그와 useEffect 페칭을 걷어낸다. TanStack Query를 쓴다면 거의 모든 데이터 읽기가 여기서 시작하고, queryKey·staleTime·status/fetchStatus의 구분만 잡으면 나머지 훅은 그 변주다.
Reference