UNPKG

@suspensive/react-query-4

Version:

Suspensive interfaces for @tanstack/react-query@4

250 lines (192 loc) 8.3 kB
--- 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.