or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

Files

docs

configuration.mdcore-query-hooks.mdindex.mdmutations.mdproviders-context.mdsuspense-integration.mdutilities.md

suspense-integration.mddocs/

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.