무엇
useSuspenseQuery는 로딩과 에러 처리를 컴포넌트 바깥으로 넘기는 useQuery 변형이다. 로딩 중에는 React Suspense가 fallback을 띄우고, 에러는 에러 바운더리로 던진다.
그 대가로 컴포넌트 안에서는 data가 항상 정의돼 있다. isPending 분기를 쓸 필요가 없다.
const { data } = useSuspenseQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// data는 undefined가 아니다 — 바로 쓴다
무슨 일이 일어나나
useQuery는 로딩·에러·성공을 반환값으로 표현해 컴포넌트가 직접 분기했다. useSuspenseQuery는 그 두 상태를 React 메커니즘에 위임한다. 데이터가 아직 없으면 컴포넌트를 suspend해 가장 가까운 <Suspense>의 fallback이 뜨고, 쿼리가 실패하면 에러를 throw해 가장 가까운 에러 바운더리가 잡는다.
그래서 status는 success 또는 error뿐이고 pending이 없다. 컴포넌트가 실제로 렌더되는 시점은 이미 데이터가 있는 순간뿐이라, data가 정의됨을 타입으로 보장한다.
이 위임 때문에 일부 옵션이 막힌다. enabled·throwOnError·placeholderData는 쓸 수 없다 — 로딩·에러를 Suspense와 바운더리가 대신 다루기 때문이다. 조건부 실행이 필요하면 useSuspenseQuery로는 표현할 수 없어 일반 useQuery를 써야 한다. 또한 이 훅에서는 쿼리 취소(cancellation)가 동작하지 않는다.
사용법
데이터를 쓰는 컴포넌트를 <Suspense>와 에러 바운더리로 감싼다. 로딩·에러 UI는 바깥에서 한 번만 정의한다.
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { useSuspenseQuery } from '@tanstack/react-query'
function TodoList() {
const { data } = useSuspenseQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// 분기 없이 바로 렌더
return <ul>{data.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
}
export function App() {
return (
<ErrorBoundary fallback={<p>에러</p>}>
<Suspense fallback={<p>로딩 중</p>}>
<TodoList />
</Suspense>
</ErrorBoundary>
)
}
실무 예시
여러 데이터를 나란히 읽어도 컴포넌트 본문이 분기 없이 평평해진다. 로딩·에러는 한 겹의 바운더리가 공통으로 처리한다.
function Dashboard() {
const { data: user } = useSuspenseQuery({
queryKey: ['user'],
queryFn: fetchUser,
})
const { data: stats } = useSuspenseQuery({
queryKey: ['stats'],
queryFn: fetchStats,
})
// user, stats 둘 다 정의 보장 — 곧장 조합
return <Header name={user.name} score={stats.score} />
}
왜 중요한가
Suspense 기반 데이터 페칭은 로딩·에러 처리를 컴포넌트 트리의 경계로 끌어올려, 개별 컴포넌트를 성공 경로만 신경 쓰게 만든다. 코드가 깔끔해지는 대신 Suspense·에러 바운더리 배치를 설계해야 하고 조건부 실행을 못 쓰는 제약이 따른다. Suspense를 적극 쓰는 코드베이스(특히 RSC·스트리밍 SSR 환경)에서 값지지만, 그렇지 않다면 일반 useQuery가 더 유연하다.
Reference