@suspensive/react-query-4
Version:
Suspensive interfaces for @tanstack/react-query@4
250 lines (192 loc) • 8.3 kB
Markdown
---
name: declarative-queries
description: >-
SuspenseQuery, SuspenseQueries, SuspenseInfiniteQuery render-prop components
for fetching in JSX without hook wrapper components. Load when placing
queries at the same depth as Suspense/ErrorBoundary, running parallel
queries, using select, integrating queryOptions/infiniteQueryOptions, or
replacing useSuspenseQuery wrapper components.
metadata:
type: sub-skill
library: '@suspensive/react-query-4'
library_version: 3.21.2
framework: react
requires: ['react-query']
sources:
- 'toss/suspensive:docs/suspensive.org/src/content/en/docs/react-query/SuspenseQuery.mdx'
- 'toss/suspensive:docs/suspensive.org/src/content/en/docs/react-query/SuspenseQueries.mdx'
- 'toss/suspensive:docs/suspensive.org/src/content/en/docs/react-query/SuspenseInfiniteQuery.mdx'
- 'toss/suspensive:docs/suspensive.org/src/content/en/docs/react-query/motivation.mdx'
---
This skill builds on react-query. Read ../SKILL.md first.
SuspenseQuery/SuspenseQueries/SuspenseInfiniteQuery move data fetching into JSX so what suspends is visible at the same depth as the Suspense and ErrorBoundary handling it, and child components stay presentational.
## Setup
```bash
npm install @suspensive/react-query-4 @suspensive/react @tanstack/react-query@4
```
Components come from '@suspensive/react-query-4'; queryOptions/infiniteQueryOptions come from '@tanstack/react-query' (official in TSQ 4.40+/4.41+; the Suspensive re-exports are deprecated).
## Core Patterns
### Parallel queries at one depth with SuspenseQuery
```tsx
'use client'
import { ErrorBoundary, Suspense } from '@suspensive/react'
import { SuspenseQuery } from '@suspensive/react-query-4'
import { PostListItem, UserProfile } from '~/components'
import { postsQueryOptions, userQueryOptions } from '~/queries'
export const PostsPage = ({ userId }: { userId: number }) => (
<ErrorBoundary fallback={({ error }) => <>{error.message}</>}>
<Suspense fallback={'loading...'}>
<SuspenseQuery {...userQueryOptions(userId)}>
{({ data: user }) => <UserProfile key={user.id} {...user} />}
</SuspenseQuery>
<SuspenseQuery {...postsQueryOptions(userId)} select={(posts) => posts.filter(({ isPublic }) => isPublic)}>
{({ data: posts }) => posts.map((post) => <PostListItem key={post.id} {...post} />)}
</SuspenseQuery>
</Suspense>
</ErrorBoundary>
)
```
Both queries fetch in parallel; the boundary scope is obvious because both sources of suspension sit directly under `Suspense`.
### Combined results with SuspenseQueries
```tsx
'use client'
import { ErrorBoundary, Suspense } from '@suspensive/react'
import { SuspenseQueries } from '@suspensive/react-query-4'
import { PostListItem, UserProfile } from '~/components'
import { postsQueryOptions, userQueryOptions } from '~/queries'
export const PostsPage = ({ userId }: { userId: number }) => (
<ErrorBoundary fallback={({ error }) => <>{error.message}</>}>
<Suspense fallback={'loading...'}>
<SuspenseQueries queries={[userQueryOptions(userId), postsQueryOptions(userId)]}>
{([{ data: user }, { data: posts }]) => (
<>
<UserProfile {...user} />
{posts.map((post) => (
<PostListItem key={post.id} {...post} />
))}
</>
)}
</SuspenseQueries>
</Suspense>
</ErrorBoundary>
)
```
Use SuspenseQueries when one render needs both results together (useSuspenseQueries in JSX).
### Infinite scrolling with SuspenseInfiniteQuery
TSQ v4 infinite queries take no `initialPageParam` — default the cursor in the queryFn signature instead.
```tsx
'use client'
import { ErrorBoundary, Suspense } from '@suspensive/react'
import { SuspenseInfiniteQuery } from '@suspensive/react-query-4'
import { infiniteQueryOptions } from '@tanstack/react-query'
import { PostListItem } from '~/components'
import { getPosts } from '~/api'
const postsInfiniteQueryOptions = (userId: number) =>
infiniteQueryOptions({
queryKey: ['users', userId, 'posts'],
queryFn: ({ pageParam = 0 }) => getPosts(userId, pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
export const InfinitePostsPage = ({ userId }: { userId: number }) => (
<ErrorBoundary fallback={({ error }) => <>{error.message}</>}>
<Suspense fallback={'loading...'}>
<SuspenseInfiniteQuery {...postsInfiniteQueryOptions(userId)}>
{({ data, fetchNextPage, hasNextPage }) => (
<>
{data.pages.flatMap((page) => page.posts.map((post) => <PostListItem key={post.id} {...post} />))}
<button type="button" disabled={!hasNextPage} onClick={() => fetchNextPage()}>
Load More
</button>
</>
)}
</SuspenseInfiniteQuery>
</Suspense>
</ErrorBoundary>
)
```
## Common Mistakes
### [MEDIUM] Wrapper components created only to call useSuspenseQuery
Wrong:
```tsx
const UserInfo = ({ userId }: { userId: number }) => {
const { data } = useSuspenseQuery(userQueryOptions(userId))
return <UserProfile {...data} />
}
```
Correct:
```tsx
<SuspenseQuery {...userQueryOptions(userId)}>{({ data }) => <UserProfile {...data} />}</SuspenseQuery>
```
Hooks force an extra child component under Suspense whose name hides that it suspends; SuspenseQuery keeps fetching visible in JSX so only presentational components remain.
Source: docs/suspensive.org/src/content/en/docs/react-query/motivation.mdx
### [HIGH] SuspenseQuery without an ancestor Suspense
Wrong:
```tsx
<SuspenseQuery {...postQueryOptions(id)}>{({ data }) => <Post data={data} />}</SuspenseQuery>
```
Correct:
```tsx
<Suspense fallback={<PostSkeleton />}>
<SuspenseQuery {...postQueryOptions(id)}>{({ data }) => <Post data={data} />}</SuspenseQuery>
</Suspense>
```
The component suspends; with no boundary above it, suspension bubbles to the nearest ancestor Suspense or the app root, blanking unrelated UI.
Source: https://github.com/toss/suspensive/issues/1654
### [HIGH] Rendering SuspenseQuery in a React Server Component
Wrong:
```tsx
// app/page.tsx — Server Component
import { SuspenseQuery } from '@suspensive/react-query-4'
export default function Page() {
return (
<SuspenseQuery queryKey={['posts']} queryFn={getPosts}>
{({ data }) => <PostList posts={data} />}
</SuspenseQuery>
)
}
```
Correct:
```tsx
'use client'
import { SuspenseQuery } from '@suspensive/react-query-4'
export const Posts = () => (
<SuspenseQuery queryKey={['posts']} queryFn={getPosts}>
{({ data }) => <PostList posts={data} />}
</SuspenseQuery>
)
```
Render-prop children are functions and cannot cross the RSC serialization boundary; keep these components inside 'use client' modules.
Source: https://github.com/toss/suspensive/issues/1563
### [HIGH] Passing enabled or placeholderData to suspense queries
Wrong:
```tsx
<SuspenseQuery queryKey={['user', userId]} queryFn={getUser} enabled={!!userId}>
{({ data }) => <UserProfile {...data} />}
</SuspenseQuery>
```
Correct:
```tsx
{
userId ? (
<SuspenseQuery queryKey={['user', userId]} queryFn={() => getUser(userId)}>
{({ data }) => <UserProfile {...data} />}
</SuspenseQuery>
) : null
}
```
Suspense queries must guarantee data, so enabled/placeholderData are excluded from their option types — express conditionality with JSX instead.
Source: docs/suspensive.org/src/content/en/docs/react-query/migration (v2 breaking changes)
### [MEDIUM] Checking isLoading/isError on suspense query results
Wrong:
```tsx
<SuspenseQuery {...postsQueryOptions()}>
{({ data, isLoading }) => (isLoading ? <Spinner /> : <List data={data} />)}
</SuspenseQuery>
```
Correct:
```tsx
<SuspenseQuery {...postsQueryOptions()}>{({ data }) => <List data={data} />}</SuspenseQuery>
```
Suspense query result types have no loading/error branches — `data` is always the success type because Suspense and ErrorBoundary guarantee it, so guards are dead code.
Source: docs/suspensive.org/src/content/en/docs/react-query/motivation.mdx
See also: ../mutations/SKILL.md for Mutation in list rows, ../prefetching/SKILL.md to warm the cache before these components suspend, ../../compositions/suspensive-react/SKILL.md for ErrorBoundary reset wiring around SuspenseQuery.