0
# Suspense Integration
1
2
React Suspense-compatible versions of query hooks that suspend component rendering until data is available.
3
4
## useSuspenseQuery
5
6
**Suspense-enabled version of useQuery that suspends component rendering until data is available**
7
8
```typescript { .api }
9
function useSuspenseQuery<
10
TQueryFnData = unknown,
11
TError = DefaultError,
12
TData = TQueryFnData,
13
TQueryKey extends QueryKey = QueryKey,
14
>(
15
options: UseSuspenseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
16
queryClient?: QueryClient,
17
): UseSuspenseQueryResult<TData, TError>
18
```
19
20
### Options
21
22
```typescript { .api }
23
interface UseSuspenseQueryOptions<
24
TQueryFnData = unknown,
25
TError = DefaultError,
26
TData = TQueryFnData,
27
TQueryKey extends QueryKey = QueryKey,
28
> extends Omit<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
29
'queryFn' | 'enabled' | 'throwOnError' | 'placeholderData'> {
30
queryFn?: Exclude<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>['queryFn'], SkipToken>
31
}
32
```
33
34
**Key Differences from useQuery:**
35
- `enabled` is always `true` (cannot be disabled)
36
- `throwOnError` is always `true` (errors are thrown to Error Boundaries)
37
- `placeholderData` is not supported (component suspends instead)
38
- `queryFn` cannot be `skipToken`
39
40
### Result
41
42
```typescript { .api }
43
interface UseSuspenseQueryResult<TData = unknown, TError = DefaultError>
44
extends Omit<DefinedQueryObserverResult<TData, TError>, 'isPlaceholderData' | 'promise'> {
45
data: TData // Always defined (never undefined)
46
isPlaceholderData: false // Always false
47
}
48
```
49
50
**Guaranteed Properties:**
51
- `data` is always defined (never undefined)
52
- `isSuccess` is always `true`
53
- `isLoading` and `isPending` are always `false`
54
- `isPlaceholderData` is always `false`
55
56
### Basic Usage
57
58
```typescript { .api }
59
import { Suspense } from 'react'
60
import { useSuspenseQuery } from '@tanstack/react-query'
61
62
interface User {
63
id: number
64
name: string
65
email: string
66
}
67
68
function UserProfile({ userId }: { userId: number }) {
69
// No need to check for loading states or undefined data
70
const { data: user } = useSuspenseQuery<User>({
71
queryKey: ['user', userId],
72
queryFn: async () => {
73
const response = await fetch(`/api/users/${userId}`)
74
if (!response.ok) {
75
throw new Error('Failed to fetch user')
76
}
77
return response.json()
78
}
79
})
80
81
// user is guaranteed to be defined here
82
return (
83
<div>
84
<h1>{user.name}</h1>
85
<p>{user.email}</p>
86
</div>
87
)
88
}
89
90
function App() {
91
return (
92
<Suspense fallback={<div>Loading user...</div>}>
93
<UserProfile userId={1} />
94
</Suspense>
95
)
96
}
97
```
98
99
### With Error Boundary
100
101
```typescript { .api }
102
import { ErrorBoundary } from 'react-error-boundary'
103
104
function UserDashboard({ userId }: { userId: number }) {
105
const { data: user } = useSuspenseQuery({
106
queryKey: ['user', userId],
107
queryFn: () => fetchUser(userId)
108
})
109
110
const { data: posts } = useSuspenseQuery({
111
queryKey: ['user-posts', userId],
112
queryFn: () => fetchUserPosts(userId)
113
})
114
115
return (
116
<div>
117
<h1>{user.name}</h1>
118
<div>Posts: {posts.length}</div>
119
</div>
120
)
121
}
122
123
function App() {
124
return (
125
<ErrorBoundary fallback={<div>Something went wrong</div>}>
126
<Suspense fallback={<div>Loading dashboard...</div>}>
127
<UserDashboard userId={1} />
128
</Suspense>
129
</ErrorBoundary>
130
)
131
}
132
```
133
134
## useSuspenseInfiniteQuery
135
136
**Suspense-enabled version of useInfiniteQuery for incremental loading with suspense**
137
138
```typescript { .api }
139
function useSuspenseInfiniteQuery<
140
TQueryFnData,
141
TError = DefaultError,
142
TData = InfiniteData<TQueryFnData>,
143
TQueryKey extends QueryKey = QueryKey,
144
TPageParam = unknown,
145
>(
146
options: UseSuspenseInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>,
147
queryClient?: QueryClient,
148
): UseSuspenseInfiniteQueryResult<TData, TError>
149
```
150
151
### Options
152
153
```typescript { .api }
154
interface UseSuspenseInfiniteQueryOptions<
155
TQueryFnData = unknown,
156
TError = DefaultError,
157
TData = TQueryFnData,
158
TQueryKey extends QueryKey = QueryKey,
159
TPageParam = unknown,
160
> extends Omit<UseInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>,
161
'queryFn' | 'enabled' | 'throwOnError' | 'placeholderData'> {
162
queryFn?: Exclude<UseInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>['queryFn'], SkipToken>
163
}
164
```
165
166
### Result
167
168
```typescript { .api }
169
interface UseSuspenseInfiniteQueryResult<TData = unknown, TError = DefaultError>
170
extends Omit<DefinedInfiniteQueryObserverResult<TData, TError>, 'isPlaceholderData' | 'promise'> {
171
data: InfiniteData<TData> // Always defined
172
isPlaceholderData: false // Always false
173
}
174
```
175
176
### Basic Usage
177
178
```typescript { .api }
179
interface PostsPage {
180
posts: Post[]
181
nextCursor?: number
182
}
183
184
function InfinitePostsList() {
185
const {
186
data,
187
fetchNextPage,
188
hasNextPage,
189
isFetchingNextPage
190
} = useSuspenseInfiniteQuery<PostsPage>({
191
queryKey: ['posts'],
192
queryFn: async ({ pageParam = 0 }) => {
193
const response = await fetch(`/api/posts?cursor=${pageParam}`)
194
return response.json()
195
},
196
initialPageParam: 0,
197
getNextPageParam: (lastPage) => lastPage.nextCursor
198
})
199
200
// data is guaranteed to be defined
201
return (
202
<div>
203
{data.pages.map((page, i) => (
204
<div key={i}>
205
{page.posts.map((post) => (
206
<div key={post.id}>
207
<h3>{post.title}</h3>
208
<p>{post.excerpt}</p>
209
</div>
210
))}
211
</div>
212
))}
213
214
<button
215
onClick={() => fetchNextPage()}
216
disabled={!hasNextPage || isFetchingNextPage}
217
>
218
{isFetchingNextPage ? 'Loading more...' : 'Load More'}
219
</button>
220
</div>
221
)
222
}
223
224
function App() {
225
return (
226
<Suspense fallback={<div>Loading posts...</div>}>
227
<InfinitePostsList />
228
</Suspense>
229
)
230
}
231
```
232
233
## useSuspenseQueries
234
235
**Suspense-enabled version of useQueries for parallel query execution with suspense**
236
237
```typescript { .api }
238
function useSuspenseQueries<
239
T extends Array<any>,
240
TCombinedResult = SuspenseQueriesResults<T>,
241
>(
242
options: {
243
queries: readonly [...SuspenseQueriesOptions<T>]
244
combine?: (result: SuspenseQueriesResults<T>) => TCombinedResult
245
},
246
queryClient?: QueryClient,
247
): TCombinedResult
248
```
249
250
### Types
251
252
```typescript { .api }
253
type SuspenseQueriesOptions<T extends Array<any>> = {
254
[K in keyof T]: UseSuspenseQueryOptions<any, any, any, any>
255
}
256
257
type SuspenseQueriesResults<T extends Array<any>> = {
258
[K in keyof T]: UseSuspenseQueryResult<any, any>
259
}
260
```
261
262
### Basic Usage
263
264
```typescript { .api }
265
function UserDashboard({ userId }: { userId: number }) {
266
const [userQuery, postsQuery, followersQuery] = useSuspenseQueries({
267
queries: [
268
{
269
queryKey: ['user', userId],
270
queryFn: () => fetchUser(userId)
271
},
272
{
273
queryKey: ['user-posts', userId],
274
queryFn: () => fetchUserPosts(userId)
275
},
276
{
277
queryKey: ['user-followers', userId],
278
queryFn: () => fetchUserFollowers(userId)
279
}
280
]
281
})
282
283
// All data is guaranteed to be defined
284
return (
285
<div>
286
<h1>{userQuery.data.name}</h1>
287
<div>Posts: {postsQuery.data.length}</div>
288
<div>Followers: {followersQuery.data.length}</div>
289
</div>
290
)
291
}
292
293
function App() {
294
return (
295
<Suspense fallback={<div>Loading dashboard...</div>}>
296
<UserDashboard userId={1} />
297
</Suspense>
298
)
299
}
300
```
301
302
### With Combine Function
303
304
```typescript { .api }
305
function StatsOverview({ userIds }: { userIds: number[] }) {
306
const stats = useSuspenseQueries({
307
queries: userIds.map(id => ({
308
queryKey: ['user-stats', id],
309
queryFn: () => fetchUserStats(id),
310
})),
311
combine: (results) => ({
312
totalUsers: results.length,
313
totalPosts: results.reduce((sum, result) => sum + result.data.postCount, 0),
314
totalFollowers: results.reduce((sum, result) => sum + result.data.followerCount, 0),
315
users: results.map(result => result.data)
316
})
317
})
318
319
return (
320
<div>
321
<h2>Platform Statistics</h2>
322
<div>Total Users: {stats.totalUsers}</div>
323
<div>Total Posts: {stats.totalPosts}</div>
324
<div>Total Followers: {stats.totalFollowers}</div>
325
326
<h3>Top Users</h3>
327
{stats.users
328
.sort((a, b) => b.followerCount - a.followerCount)
329
.slice(0, 5)
330
.map(user => (
331
<div key={user.id}>
332
{user.name} - {user.followerCount} followers
333
</div>
334
))}
335
</div>
336
)
337
}
338
```
339
340
## Suspense Best Practices
341
342
### Nested Suspense Boundaries
343
344
```typescript { .api }
345
function App() {
346
return (
347
<div>
348
{/* Top-level suspense for critical data */}
349
<Suspense fallback={<AppShell />}>
350
<Navigation />
351
<main>
352
{/* Nested suspense for page-specific data */}
353
<Suspense fallback={<PageSkeleton />}>
354
<Route path="/users/:id" component={UserPage} />
355
</Suspense>
356
</main>
357
</Suspense>
358
</div>
359
)
360
}
361
362
function UserPage({ userId }: { userId: number }) {
363
// This will suspend until user data is loaded
364
const { data: user } = useSuspenseQuery({
365
queryKey: ['user', userId],
366
queryFn: () => fetchUser(userId)
367
})
368
369
return (
370
<div>
371
<UserHeader user={user} />
372
373
{/* Nested suspense for secondary data */}
374
<Suspense fallback={<div>Loading posts...</div>}>
375
<UserPosts userId={userId} />
376
</Suspense>
377
</div>
378
)
379
}
380
```
381
382
### Progressive Loading with Multiple Boundaries
383
384
```typescript { .api }
385
function BlogPost({ postId }: { postId: number }) {
386
// Load post data first
387
const { data: post } = useSuspenseQuery({
388
queryKey: ['post', postId],
389
queryFn: () => fetchPost(postId)
390
})
391
392
return (
393
<article>
394
<h1>{post.title}</h1>
395
<div>{post.content}</div>
396
397
{/* Load comments separately to avoid blocking post display */}
398
<Suspense fallback={<div>Loading comments...</div>}>
399
<Comments postId={postId} />
400
</Suspense>
401
402
{/* Load related posts separately */}
403
<Suspense fallback={<div>Loading related posts...</div>}>
404
<RelatedPosts categoryId={post.categoryId} />
405
</Suspense>
406
</article>
407
)
408
}
409
```
410
411
### Error Boundaries with Suspense
412
413
```typescript { .api }
414
import { QueryErrorResetBoundary } from '@tanstack/react-query'
415
416
function App() {
417
return (
418
<QueryErrorResetBoundary>
419
{({ reset }) => (
420
<ErrorBoundary
421
onReset={reset}
422
fallbackRender={({ error, resetErrorBoundary }) => (
423
<div>
424
<h2>Something went wrong:</h2>
425
<pre>{error.message}</pre>
426
<button onClick={resetErrorBoundary}>
427
Try again
428
</button>
429
</div>
430
)}
431
>
432
<Suspense fallback={<div>Loading...</div>}>
433
<UserDashboard />
434
</Suspense>
435
</ErrorBoundary>
436
)}
437
</QueryErrorResetBoundary>
438
)
439
}
440
```
441
442
### Prefetching with Suspense
443
444
```typescript { .api }
445
function UsersList() {
446
const { data: users } = useSuspenseQuery({
447
queryKey: ['users'],
448
queryFn: fetchUsers
449
})
450
451
const queryClient = useQueryClient()
452
453
return (
454
<div>
455
{users.map(user => (
456
<div
457
key={user.id}
458
onMouseEnter={() => {
459
// Prefetch user details on hover
460
queryClient.prefetchQuery({
461
queryKey: ['user', user.id],
462
queryFn: () => fetchUser(user.id),
463
staleTime: 5 * 60 * 1000
464
})
465
}}
466
>
467
<Link to={`/users/${user.id}`}>
468
{user.name}
469
</Link>
470
</div>
471
))}
472
</div>
473
)
474
}
475
```
476
477
### Conditional Suspense Queries
478
479
```typescript { .api }
480
function ConditionalData({ showDetails, userId }: { showDetails: boolean, userId: number }) {
481
const { data: user } = useSuspenseQuery({
482
queryKey: ['user', userId],
483
queryFn: () => fetchUser(userId)
484
})
485
486
return (
487
<div>
488
<h2>{user.name}</h2>
489
490
{showDetails && (
491
<Suspense fallback={<div>Loading details...</div>}>
492
<UserDetails userId={userId} />
493
</Suspense>
494
)}
495
</div>
496
)
497
}
498
499
function UserDetails({ userId }: { userId: number }) {
500
const { data: details } = useSuspenseQuery({
501
queryKey: ['user-details', userId],
502
queryFn: () => fetchUserDetails(userId)
503
})
504
505
return (
506
<div>
507
<p>Bio: {details.bio}</p>
508
<p>Location: {details.location}</p>
509
</div>
510
)
511
}
512
```
513
514
## Migration from Regular Hooks
515
516
### Before (useQuery)
517
518
```typescript { .api }
519
function UserProfile({ userId }: { userId: number }) {
520
const { data: user, isLoading, error } = useQuery({
521
queryKey: ['user', userId],
522
queryFn: () => fetchUser(userId)
523
})
524
525
if (isLoading) return <div>Loading...</div>
526
if (error) return <div>Error: {error.message}</div>
527
if (!user) return null
528
529
return (
530
<div>
531
<h1>{user.name}</h1>
532
<p>{user.email}</p>
533
</div>
534
)
535
}
536
```
537
538
### After (useSuspenseQuery)
539
540
```typescript { .api }
541
function UserProfile({ userId }: { userId: number }) {
542
const { data: user } = useSuspenseQuery({
543
queryKey: ['user', userId],
544
queryFn: () => fetchUser(userId)
545
})
546
547
// No need for loading/error checks - handled by Suspense/ErrorBoundary
548
return (
549
<div>
550
<h1>{user.name}</h1>
551
<p>{user.email}</p>
552
</div>
553
)
554
}
555
556
// Wrap in Suspense and ErrorBoundary at higher level
557
function App() {
558
return (
559
<ErrorBoundary fallback={<ErrorFallback />}>
560
<Suspense fallback={<LoadingFallback />}>
561
<UserProfile userId={1} />
562
</Suspense>
563
</ErrorBoundary>
564
)
565
}
566
```
567
568
The suspense integration hooks provide a declarative way to handle loading and error states at the boundary level, leading to cleaner component code and better user experience with coordinated loading states.