React TanStack Query v5 useSuspenseQuery + ErrorBoundary: Declarative Async UI Patterns in Practice
A TypeScript practical guide to completely eliminating isLoading/isError branching from your components
To be honest, I used to write the same boilerplate every time I built an async component. If isLoading, show a spinner; if isError, show an error message; otherwise, finally render the data. It doesn't look too bad in a single component, but the problem is scale. Once you have 50 components, each one carries its own state branches, and a single missed branch during refactoring becomes a production bug. I actually experienced a white screen in a project I worked on because I forgot to check whether data was undefined — that's when I first thought, "Is there a way to prevent this structurally?"
By combining TanStack Query v5's useSuspenseQuery with React's Suspense and ErrorBoundary, you can completely separate the three states — loading, error, and success — into distinct boundaries in the component tree, and data is always guaranteed to be of type T. The boundary-based structure may feel unfamiliar at first, but once it clicks, you'll wonder why you weren't using it all along.
This article is intended for readers who have some experience with React and TanStack Query. It assumes you already have
QueryClientandQueryClientProviderset up. If you're looking foruseSuspenseQueryusage, React Suspense patterns, or TanStack Query v5 migration guidance, read on.
TL;DR
useSuspenseQuery→datais alwaysT(no undefined, no defensive code needed)<Suspense fallback={...}>→ handles loading state<ErrorBoundary>+QueryErrorResetBoundary→ handles errors + retry
Core Concepts
Imperative vs Declarative: What Actually Changes
The most intuitive way to understand the difference is to see the same feature written both ways, side by side.
Imperative approach (traditional useQuery)
function UserProfile({ id }: { id: string }) {
const { data, isLoading, isError, error } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
});
if (isLoading) return <Spinner />;
if (isError) return <ErrorMessage message={error.message} />;
if (!data) return null; // defensive code just to appease TypeScript
return (
<div className="profile">
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}Declarative approach (useSuspenseQuery)
function UserProfile({ id }: { id: string }) {
const { data } = useSuspenseQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
});
// isLoading, isError, and if (!data) branches are all gone
return (
<div className="profile">
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}Three lines — isLoading, isError, and if (!data) — are gone. Where did loading and errors go? They were delegated to boundaries higher up in the component tree.
| State | Responsibility |
|---|---|
| Loading | <React.Suspense fallback={...}> |
| Error | <ErrorBoundary> |
| Success | The actual component (data is always defined) |
useSuspenseQuery — A Hook Where data Is Always T
Up through TanStack Query v4, enabling Suspense meant passing an experimental suspense: true option to useQuery. In v5, a dedicated hook was split out as a stable, official API. The key change is in the return type.
// useQuery: data is User | undefined
const { data } = useQuery<User>({ queryKey: ['user', id], queryFn: ... });
// → need to check for undefined before accessing data.name
// useSuspenseQuery: data is always User
const { data } = useSuspenseQuery<User>({ queryKey: ['user', id], queryFn: ... });
// → can access data.name directly — safe at both compile time and runtimeCore behavior: While loading, the component itself doesn't render — the nearest
Suspenseboundary shows itsfallbackinstead. By the time the component executes, data is already ready, so it can never beundefined.
My favorite part is being able to use data.name directly without any warnings, even in TypeScript strict mode. It's impressive how cleanly the compiler and runtime can be made to offer the same guarantee.
The difference between useSuspenseQuery and throwOnError
You can also propagate errors to an ErrorBoundary from useQuery by setting throwOnError: true. These two approaches can be confusing, but the difference is clear.
// Using throwOnError: true
const { data, isLoading } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
throwOnError: true,
});
// data is still User | undefined
// you still need the isLoading branch and undefined checkthrowOnError: true only sends errors to the ErrorBoundary — data still doesn't narrow to T, and the isLoading branch remains. The distinction is that useSuspenseQuery resolves all three at once: loading, errors, and types.
The interaction between staleTime and Suspense
This tripped me up at first too: useSuspenseQuery will not suspend if the cache is in a fresh state. For example, if you set staleTime: Infinity, it will render immediately from cache without showing a loading screen when data is available. If you're ever puzzled by why a loading skeleton isn't appearing after bumping staleTime, this behavior is likely the reason. Verify whether it's intentional, then adjust the staleTime value accordingly.
Alternative to the enabled option: skipToken
useSuspenseQuery does not support enabled: false. The simplest approach is to conditionally render the component (isLoggedIn && <UserProfile />), but v5 also officially supports skipToken. It's a more practical choice when prop drilling is deep or when mounting/unmounting the component carries a cost.
import { skipToken, useSuspenseQuery } from '@tanstack/react-query';
function UserProfile({ id }: { id: string | null }) {
const { data } = useSuspenseQuery({
queryKey: ['user', id],
queryFn: id ? () => fetchUser(id) : skipToken, // skip query if no id
});
return <div>{data.name}</div>;
}QueryErrorResetBoundary — Retrying After an Error
To implement a "retry" button when an error occurs, you need QueryErrorResetBoundary. It resets the query's error state in sync when the ErrorBoundary resets.
Without it, resetting the ErrorBoundary leaves the query's error state intact, causing it to immediately return to the error screen. In practice, if you've built a retry button that doesn't make the error screen go away when clicked, there's a good chance this component is missing.
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';
function UserProfilePage({ id }: { id: string }) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
FallbackComponent={({ error, resetErrorBoundary }) => (
<div>
<p>An error occurred: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile id={id} />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
react-error-boundary: React's ErrorBoundary currently can only be natively implemented as a class component. Thereact-error-boundarylibrary wraps this in a function-component-friendly way, providing convenience APIs likeFallbackComponent,resetKeys, andonReset.
Practical Application
Example 1: Basic State Separation Pattern
This is the most common configuration in real-world codebases. Creating one reusable ErrorFallback component lets you use it across many places right away.
import { useSuspenseQuery, QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';
// Reusable error fallback component
function ErrorFallback({
error,
resetErrorBoundary,
}: {
error: Error;
resetErrorBoundary: () => void;
}) {
return (
<div role="alert" className="error-container">
<p>An error occurred: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
// ✅ Component focused solely on the success state
function UserProfile({ id }: { id: string }) {
const { data } = useSuspenseQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
});
return (
<div className="profile">
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
// ✅ Parent component that declares loading and error boundaries
function UserProfilePage({ id }: { id: string }) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset} FallbackComponent={ErrorFallback}>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile id={id} />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}| Code section | Role |
|---|---|
UserProfile |
Handles only success-state UI; no isLoading/isError branching |
Suspense fallback |
Skeleton/spinner shown while data is loading |
ErrorBoundary |
Error screen and retry handling when the query fails |
QueryErrorResetBoundary |
Synchronizes query error state reset on retry |
Example 2: Nested Suspense for Independent Loading UIs
When I first built a dashboard, the slow feed data was causing the header to be hidden behind a skeleton too — that's what prompted me to start splitting boundaries. It's a situation you encounter often in practice: by dividing Suspense boundaries per region, one region can still be loading while others are already displayed.
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';
function Dashboard() {
return (
<div className="dashboard">
{/* Header handles loading and errors independently */}
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset} FallbackComponent={ErrorFallback}>
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
<div className="grid grid-cols-2 gap-4">
{/* Stats and feed are independent of each other */}
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset} FallbackComponent={ErrorFallback}>
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset} FallbackComponent={ErrorFallback}>
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
</div>
</div>
);
}If you wrap everything in a single Suspense, the header and stats that have already finished loading get covered by skeletons while the feed data is still slow to arrive. Simply making boundaries more granular produces a dramatically better UX.
If the repeated
QueryErrorResetBoundary + ErrorBoundary + Suspensecombination makes the code verbose, abstracting it with theSuspensivelibrary's<SuspenseQuery />component or a custom wrapper is also a great option.
Example 3: Parallel Queries — useSuspenseQueries
When a single component needs to fetch multiple pieces of data simultaneously, you can use useSuspenseQueries. Both queries run in parallel, and the component only renders once both have completed.
import { useSuspenseQueries } from '@tanstack/react-query';
function ProductDetail({ id }: { id: string }) {
const [{ data: product }, { data: reviews }] = useSuspenseQueries({
queries: [
{
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
},
{
queryKey: ['reviews', id],
queryFn: () => fetchReviews(id),
},
],
});
// both product and reviews are always defined
return (
<div>
<h1>{product.title}</h1>
<p>{product.description}</p>
<ReviewList reviews={reviews} />
</div>
);
}If you need to implement infinite scrolling, useSuspenseInfiniteQuery works the same way. The return type guarantee and Suspense integration are identical.
Example 4: Streaming SSR with Next.js App Router
If you're using Next.js 13+ App Router, the loading.tsx and error.tsx file conventions are the framework's own adoption of this exact pattern. You can also use Suspense directly for finer-grained control.
// app/users/[id]/page.tsx (based on Next.js 14)
import { Suspense } from 'react';
import { UserDetail } from './UserDetail';
import { UserSkeleton } from './UserSkeleton';
export default function UserPage({
params,
}: {
// In Next.js 15+, params changes to Promise<{ id: string }>
// Be sure to check the Next.js version you're using
params: { id: string };
}) {
return (
// HTML streaming: chunks are sent per Suspense boundary
<Suspense fallback={<UserSkeleton />}>
<UserDetail id={params.id} />
</Suspense>
);
}Render-as-You-Fetch: Unlike the traditional Fetch-on-Render approach (where fetching starts after rendering), this pattern runs
prefetchQueryon the server in advance and receives it on the client via Suspense. Since fetching and rendering proceed in parallel, initial load time is reduced. A detailed walkthrough of applying this pattern in practice will be covered in the next article.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Separation of concerns | Success components focus only on "when data is present"; no state branching logic needed |
| Type safety | data is narrowed from T | undefined to T, eliminating defensive code |
| Code simplification | if (isLoading) and if (isError) branches disappear from components |
| Reusability | ErrorBoundary and Suspense boundaries can be freely composed |
| Fewer bugs | Errors from accessing data during loading and other state-sync mistakes are eliminated at the source |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
No enabled option support |
enabled: false is not available in useSuspenseQuery |
Use skipToken (officially supported in v5) or separate components with conditional rendering |
| Error handling with stale data | If cached data exists, a refetch failure won't propagate to ErrorBoundary | Implement a pattern that explicitly throws when result.isStale && result.error |
Interaction with staleTime |
Fresh cache prevents suspension, so the loading screen won't appear | Verify if this is intentional, then adjust the staleTime value |
| Overly broad boundary scope | Too large a boundary causes partial failures to hide the entire UI | Break down into per-region boundaries for independent failure/loading handling |
| Mixed use with Server Components | TanStack Query hooks cannot be used directly in Next.js Server Components | Separate into Client Components and clearly define the server/client boundary |
The Most Common Mistakes in Practice
- Implementing retry without
QueryErrorResetBoundary: Resetting the ErrorBoundary leaves the query's error state intact, immediately returning to the error screen. It's strongly recommended to always use these together when building a retry feature. - Setting boundaries at too coarse a granularity: Wrapping an entire page in a single Suspense means slow sidebar data will block the main content too. Independent regions should have independent boundaries.
- Not accounting for stale cached data in error handling: Even if a previously successful query fails during a retry, the ErrorBoundary won't trigger if cached data exists. If this behavior doesn't match your intent, explicitly add handling logic.
Closing Thoughts
The combination of useSuspenseQuery + Suspense + ErrorBoundary rests on a simple principle: "each component focuses on only the one state it owns." The most tangible change this pattern brings isn't just individual productivity. When the entire team handles async state the same way, code reviews become easier and the time it takes new team members to understand component structure shrinks significantly. You'll appreciate its value once you experience a world where the question "where does this component handle loading?" simply ceases to exist.
Three steps you can start with right now:
- Install packages and verify versions: Install with
pnpm add @tanstack/react-query@^5 react-error-boundary, and if you want to visually observe Suspend states during development, addpnpm add @tanstack/react-query-devtoolsas well.useSuspenseQueryis only available as a stable API in v5 and above. - Migrate one existing component: Pick your simplest component, switch
useQuery→useSuspenseQuery, and addSuspenseandErrorBoundaryabove it. You'll see firsthand as theisLoading/isErrorbranches disappear from inside the component. - Test the retry flow: Force a request to fail in the Network tab and verify that the ErrorBoundary's retry button works correctly together with
QueryErrorResetBoundary— at that point, the full flow is complete.
References
- Suspense | TanStack Query Official Guide
- useSuspenseQuery API Reference | TanStack Query
- QueryErrorResetBoundary Reference | TanStack Query
- Suspense Official Examples | TanStack Query
- Next.js Suspense Streaming Example | TanStack Query
- react-error-boundary | GitHub (bvaughn)
- Suspensive Official Docs
- SuspenseQuery Component | Suspensive
- <Suspense> Official Docs | React
Next article: A practical guide to minimizing initial load with streaming SSR using the Render-as-You-Fetch pattern, combining TanStack Query's
prefetchQuerywith Next.js App Router