
Tanstack Query에는 useSuspenseQuery를 통해 Promise를 throw 해서 Suspense의 fallback 컴포넌트를 보여줄 수 있다.
이번 글에서는 useSuspenseQuery의 내부 동작 방식을 중심으로 Suspense와 어떻게 연결되는지 조금 더 깊게 살펴본다.
useQuery를 사용할 경우, 데이터 요청 상태에 따라 컴포넌트 내부에서 로딩, 에러, 데이터 존재 여부를 직접 분기해야 한다.
function PostContent() {
const { data, isLoading } = useQuery({...});
if (isLoading) {
return <p>게시글을 불러오는 중입니다...</p>;
}
if (!data) {
return <p>해당 게시글을 찾을 수 없습니다.</p>;
}
return (
<section>
<h2>{data.title}</h2>
<p>{data.body}</p>
</section>
);
}
이 방식은 직관적이지만 컴포넌트가 데이터 표현 로직과 로딩 상태 관리 책임을 동시에 가지게 된다는 단점이 있다. 또한 로딩 UI가 늘어날수록 컴포넌트의 관심사가 점점 흐려진다.
반면 useSuspenseQuery를 사용하면 로딩 상태를 Suspense boundary로 위임하고 컴포넌트는 “데이터가 존재하는 경우”에만 집중할 수 있다.
function PostContent() {
const { data } = useSuspenseQuery({...});
return (
<section>
<h2>{data.title}</h2>
<p>{data.body}</p>
</section>
);
}
function Post() {
return (
<Suspense fallback={<div>게시글을 불러오는 중입니다...</div>}>
<PostContent />
</Suspense>
)
}
이 구조에서는 다음과 같은 이점이 있다.
다만 Next.js와 같이 서버 사이드에서 네트워크 요청이 실행되는 환경에서는 요청 과정에서 쿠키에 접근하려 할 경우 오류가 발생할 수 있다.
이러한 경우 useSuspenseQuery를 사용하는 컴포넌트는 dynamic을 통해 클라이언트 전용 렌더링을 명시해 주어야 한다.
그렇다면 Suspense는 어떻게 “데이터를 로딩 중”이라는 사실을 인지할까?
이를 이해하기 위해 useSuspenseQuery의 구현 코드를 직접 살펴보았다.
핵심 코드는 다음과 같다.
return useBaseQuery(
{
...options,
enabled: true,
suspense: true,
throwOnError: defaultThrowOnError,
placeholderData: undefined,
},
QueryObserver,
queryClient,
) as UseSuspenseQueryResult<TData, TError>
useSuspenseQuery는 공식 문서에 명시된 useQuery의 옵션을 그대로 받되 enabled, throwOnError, placeholderData와 같은 일부 옵션을 내부에서 고정한다.
suspense: true 옵션은 v5로 넘어오며 외부 API에서는 사라진 것처럼 보이지만 내부 구현에서는 Suspense 동작을 제어하는 플래그로 사용되고 있다.
useSuspenseQuery는 공식 문서에 명시된 useQuery의 옵션을 그대로 받되 enabled, throwOnError, placeholderData와 같은 일부 옵션을 내부에서 고정한다.
특히 suspense 옵션은 v5로 넘어오며 외부 API에서는 사라진 것처럼 보이지만, 내부 구현에서는 여전히 Suspense 동작을 제어하는 핵심 플래그로 사용되고 있다.
useBaseQuery는 useQuery, useSuspenseQuery, useInfiniteQuery의 공통 기반이며, 쿼리 상태에 따라 Suspense 또는 ErrorBoundary로 흐름을 분기한다.
Suspense와 직접적으로 연결되는 코드는 다음과 같다.
// useBaseQuery.ts
const defaultedOptions = client.defaultQueryOptions(options)
// Handle suspense
if (shouldSuspend(defaultedOptions, result)) {
throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary)
}
shouldSuspend 함수는 다음 조건을 만족할 때 true를 반환한다.
// suspense.ts
export const shouldSuspend = (
defaultedOptions:
| DefaultedQueryObserverOptions<any, any, any, any, any>
| undefined,
result: QueryObserverResult<any, any>,
) => defaultedOptions?.suspense && result.isPending
suspense 옵션이 활성화되어 있고isPending) 상태라면useBaseQuery는 fetchOptimistic의 반환값을 throw 하여 렌더링을 Suspense boundary로 위임한다.
export const fetchOptimistic = (...) =>
observer.fetchOptimistic(defaultedOptions).catch(() => {
errorResetBoundary.clearReset()
})
fetchOptimistic는 내부적으로 observer.fetchOptimistic를 호출하고, 그 결과로 반환된 Promise를 그대로 반환한다.
이때 catch 구문은 Promise가 reject 되었을 경우, ErrorBoundary의 reset 상태를 초기화하여 이후 발생하는 에러가 정상적으로 다시 throw 될 수 있도록 하기 위한 처리이다.
이렇게 던져진 에러는 React의 ErrorBoundary와 TanStack Query의 QueryErrorResetBoundary를 통해 선언적으로 처리할 수 있다.
export function PostPage() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
<p>에러가 발생했습니다.</p>
<button onClick={resetErrorBoundary}>다시 시도</button>
</div>
)}
>
<Suspense fallback={<div>게시글을 불러오는 중입니다...</div>}>
<PostContent />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
)
}
그동안 useSuspenseQuery의 내부 동작에 대해 깊이 이해하지 못하고 있었는데 이번 기회를 통해 비교적 명확하게 정리할 수 있었다. 또한 코드에 대해 조사하는 과정에서 TanStack Query가 제공하는 기능의 폭이 상당히 넓다는 점도 새롭게 알게 되었다.
앞으로는 제공되는 기능들을 하나씩 직접 사용해 보며 TanStack Query의 장점을 더욱 효과적으로 활용해 보고자 한다.