What is React Suspense?
React Suspense is a built-in React component that lets you declaratively render a fallback UI while its children are still loading. It fundamentally shifts how we handle asynchronous data in React applications, moving from imperative loading state management to a more elegant declarative approach.
Traditional data fetching required managing multiple state variables--isLoading, data, error--and conditionally rendering different UI states throughout your components. Suspense eliminates this complexity by allowing you to specify a fallback that renders automatically while any child component is pending.
The real power emerges when combining Suspense with TanStack Query (formerly React Query), which provides sophisticated caching, background updates, and synchronization capabilities. Together, they create a data fetching experience that feels native to React's component model, aligning with concurrent features like Streaming SSR and selective hydration introduced in React 18.
Suspense represents a paradigm shift in how we think about async UI. Instead of components checking whether data is ready and rendering conditionally, components simply attempt to render. If they need data that isn't available yet, React suspends the rendering and displays the fallback instead. This happens automatically, without explicit loading checks in your component logic, enabling cleaner code and more predictable user experiences.
Suspense vs Traditional Loading States
The traditional approach to handling loading states in React involves creating state variables, managing transitions between loading, success, and error states, and conditionally rendering different UI elements based on those states. This pattern, while familiar, leads to verbose components and scattered loading logic throughout your application.
// Traditional approach - verbose and scattered
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchUser(userId)
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return null;
return (
<div className="user-profile">
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
With Suspense, this pattern collapses into something far more elegant:
// Suspense approach - clean and declarative
import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
function UserProfile({ userId }) {
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
return (
<div className="user-profile">
<h2>{data.name}</h2>
<p>{data.email}</p>
</div>
);
}
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserProfile userId="123" />
</Suspense>
);
}
The Suspense approach removes loading conditionals entirely from your component logic, centralizing fallback handling at the Suspense boundary level. This means your data-fetching components stay focused on rendering UI rather than managing loading states, resulting in code that's easier to read, test, and maintain. When you're building complex React applications with multiple data dependencies, combining Suspense with modern React development practices helps create more maintainable codebases.
Introducing useSuspenseQuery
TanStack Query provides the useSuspenseQuery hook specifically designed for integration with React Suspense. Unlike the standard useQuery hook that returns loading and error states you handle manually, useSuspenseQuery is designed to throw a promise when data isn't available, which triggers Suspense to render the fallback.
This distinction is crucial for understanding when to use each hook. Use useQuery when you need fine-grained control over loading UI, want to display progress indicators, or need to handle different loading states differently. Use useSuspenseQuery when you want cleaner component code and prefer to delegate loading state management to Suspense boundaries.
The useSuspenseQuery hook shares most configuration options with useQuery--query keys, fetch functions, stale times, and cache settings--but differs in its error handling approach. When a query is in loading state, useSuspenseQuery throws a special promise that React catches and uses to trigger the nearest Suspense boundary. When the query succeeds, it returns the data normally. If the query fails, it throws an error that must be caught by an Error Boundary.
Key Behavioral Differences
The fundamental difference between these hooks lies in how they handle the loading state. useQuery returns an object with isLoading, isError, isSuccess, and data properties, putting the responsibility of rendering appropriate UI states on the component developer. useSuspenseQuery, by contrast, returns only the data property and expects the component to either render successfully or suspend.
When using useSuspenseQuery, you lose access to the isLoading flag and manual error handling within the component. This is intentional--it pushes those concerns up to the Suspense and Error Boundary levels, resulting in cleaner component code that focuses on rendering rather than state management.
Basic Usage Example
Here's how you set up and use useSuspenseQuery with Suspense in your application:
import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
// First, set up the QueryClient provider at your app root
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<Suspense fallback={<LoadingSkeleton />}>
<UserProfile userId="123" />
</Suspense>
</QueryClientProvider>
);
}
The QueryClientProvider establishes the query client context that all TanStack Query hooks use. Without this provider at your app root, the hooks won't function. The Suspense component wraps any child components that use useSuspenseQuery, providing a centralized fallback that renders while any child is pending.
// Component using Suspense-aware query
function UserProfile({ userId }) {
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // Cache data for 5 minutes
});
return (
<div className="user-profile">
<h2>{data.name}</h2>
<p>{data.email}</p>
<p className="user-bio">{data.bio}</p>
</div>
);
}
The queryKey array serves as a unique identifier for this query--invalidation and refetching operations use this key. The queryFn is the async function that actually fetches the data. The staleTime configuration prevents the query from automatically refetching for five minutes, reducing unnecessary network requests and providing a smoother user experience. For teams implementing professional React applications, mastering these patterns is essential for building scalable data fetching layers.
[Sources: TanStack Query Documentation, LogRocket Blog]
Practical Tutorial Examples
Example 1: Data Fetching with Suspense
Real-world applications often need to fetch data from multiple sources simultaneously. Suspense handles this elegantly by waiting for all pending data before rendering, ensuring your UI never appears in a partially loaded state. This parallel loading pattern prevents the jarring "popcorn" effect where different parts of your interface load sequentially.
import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
// Parallel data fetching with multiple queries
function Dashboard() {
const usersQuery = useSuspenseQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
const statsQuery = useSuspenseQuery({
queryKey: ['stats'],
queryFn: fetchStats,
});
const notificationsQuery = useSuspenseQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
});
// All three queries must complete before rendering continues
return (
<div className="dashboard">
<UserList users={usersQuery.data} />
<StatsWidget stats={statsQuery.data} />
<NotificationPanel notifications={notificationsQuery.data} />
</div>
);
}
// Single Suspense boundary handles all parallel queries
function App() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>
);
}
In this pattern, the single Suspense boundary ensures that the entire dashboard either appears fully loaded or shows the skeleton placeholder. This is superior to having each widget manage its own loading state, which often results in a fragmented user experience with loading spinners appearing at different times.
Example 2: Lazy Loading Components with Suspense
Beyond data fetching, Suspense excels at code splitting. The React.lazy function combined with dynamic imports allows you to defer loading heavy components until they're needed, with Suspense providing a seamless loading experience. This dramatically reduces initial bundle sizes and improves time-to-interactive metrics, making it a key technique for performance optimization in modern React applications.
import { lazy, Suspense } from 'react';
// Dynamic import for code splitting - component loads only when rendered
const AnalyticsDashboard = lazy(
() => import('./components/AnalyticsDashboard')
);
const ReportsGenerator = lazy(
() => import('./components/ReportsGenerator')
);
const DataVisualization = lazy(
() => import('./components/DataVisualization')
);
function App() {
return (
<div className="app-container">
<header className="app-header">
<h1>Analytics Platform</h1>
</header>
<main>
<Suspense fallback={<div className="loading-card">Loading dashboard...</div>}>
<AnalyticsDashboard />
</Suspense>
<Suspense fallback={<div className="loading-card">Loading reports...</div>}>
<ReportsGenerator />
</Suspense>
<Suspense fallback={<div className="loading-card">Loading visualizations...</div>}>
<DataVisualization />
</Suspense>
</main>
</div>
);
}
Each component is now loaded as a separate JavaScript chunk, loaded only when the Suspense boundary renders. Combined with skeleton loaders as fallbacks, this pattern provides both performance benefits and a smooth user experience without jarring content flashes.
Example 3: Error Boundaries with Suspense
Since Suspense throws when data isn't available, any errors thrown during rendering--including network errors--will also be caught by Suspense. This means you must use Error Boundaries to handle error states gracefully. Error Boundaries are React components that catch JavaScript errors anywhere in their child component tree and display a fallback UI.
import { Component } from 'react';
class ErrorBoundary extends Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
// Update state to render the fallback
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Log error to monitoring service (Sentry, LogRocket, etc.)
console.error('Suspense error:', error, errorInfo);
// Optional: Send to error tracking service
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
}
render() {
if (this.state.hasError) {
return (
<div className="error-state">
<h2>Something went wrong</h2>
<p>{this.state.error?.message || 'An unexpected error occurred'}</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="retry-button"
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
// Usage with Suspense - ErrorBoundary catches both loading and error states
function App() {
return (
<ErrorBoundary>
<Suspense fallback={<LoadingSpinner message="Loading your data..." />}>
<DataComponent />
</Suspense>
</ErrorBoundary>
);
}
This pattern ensures that your application handles both loading states (via Suspense) and error states (via Error Boundary) gracefully. The Error Boundary can be made reusable and even configurable with different fallback UIs for different error types.
[Sources: Refine Dev Blog, DEV Community]
Best Practices for Suspense Performance
Avoiding Fallback Loops
One common pitfall with Suspense is creating cascading Suspense boundaries that trigger additional data fetches, leading to infinite fallback loops. This typically happens when a Suspense-wrapped component renders another component that also triggers a Suspense boundary before its data is ready.
Consider this problematic pattern where a parent Suspense triggers a child Suspense that hasn't been prefetched:
// Problematic: Cascading Suspense boundaries
function Parent() {
return (
<Suspense fallback={<ParentSkeleton />}>
<ChildWithOwnSuspense />
</Suspense>
);
}
function ChildWithOwnSuspense() {
const { data } = useSuspenseQuery({
queryKey: ['child-data'],
queryFn: fetchChildData,
});
return <ChildContent data={data} />;
}
To avoid this, structure your queries to prefetch critical data in parent components or use a single Suspense boundary for related data. If you must have nested Suspense boundaries, ensure the inner query is already cached or prefetched before rendering.
Prefetching Strategies
TanStack Query provides powerful prefetching capabilities that allow you to load data before Suspense needs it. By prefetching in parent components or during navigation events, you can ensure data is ready when components mount, eliminating the loading state entirely.
import { useQueryClient } from '@tanstack/react-query';
function UserNavigation({ userId }) {
const queryClient = useQueryClient();
// Prefetch on user interaction - data ready by click time
const handleMouseEnter = async () => {
await queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000,
});
};
return (
<nav>
<Link
to={`/users/${userId}`}
onMouseEnter={handleMouseEnter}
>
View User Profile
</Link>
</nav>
);
}
Prefetching during hover or focus events gives the browser time to load data before the user actually navigates. Combined with React Router's prefetching capabilities, this creates nearly instant page transitions. Configure appropriate staleTime values to balance data freshness with reduced network traffic.
Performance Considerations
When using Suspense with React Query, several performance factors require attention. Configure appropriate stale times to prevent excessive refetching, use the placeholderData option to show meaningful placeholders during background refetches, and consider query deduplication to avoid redundant network requests.
function ProductsList({ category }) {
const { data } = useSuspenseQuery({
queryKey: ['products', category],
queryFn: () => fetchProducts(category),
staleTime: 30 * 1000, // 30 seconds before considering data stale
placeholderData: (previousData) => previousData, // Keep showing old data during refetch
});
return (
<div className="products-grid">
{data.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
The placeholderData option with a selector returning the previous data provides a seamless transition during background refetches--users see existing data rather than loading states, improving perceived performance. Query deduplication happens automatically when the same query key is used within the deduplication window (typically around 5 minutes), preventing multiple simultaneous requests for identical data. These optimization techniques are essential for delivering exceptional user experiences in production React applications.
Why leading development teams are adopting this pattern
Declarative Data Loading
Remove loading state boilerplate from components. Specify fallback UI once and let React handle the rest.
Automatic Cache Management
TanStack Query handles caching, background updates, and deduping automatically. No manual cache invalidation needed.
Consistent User Experience
Single loading state for entire component trees prevents jarring partial renders and loading flashes.
Better Component Isolation
Data fetching logic stays with the components that need it. No prop drilling or context providers scattered throughout.
Frequently Asked Questions
Sources
- TanStack Query Documentation - Official useSuspenseQuery API reference
- LogRocket: Using Suspense and React Query Tutorial - Code examples for Suspense + React Query integration
- Refine: A Quick Start Guide to React Suspense - React Suspense API documentation and best practices
- DEV Community: Mastering React Query in 2025 - Enterprise-grade patterns for React Query with Suspense integration