UNPKG

@suspensive/react-query-4

Version:

Suspensive interfaces for @tanstack/react-query@4

412 lines (314 loc) 14.9 kB
--- name: ssr-hydration description: >- Server rendering and hydration for TanStack Query v4 in RSC frameworks: request-scoped QueryClient with createGetQueryClient, server prefetch + dehydrate + hydrate with QueriesHydration, skipSsrOnError, timeout, per-boundary HTML streaming, QueryClientConsumer. Load when setting up QueryClient in Next.js App Router, prefetching queries in Server Components, or debugging cross-request cache leaks and hydration issues. 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/createGetQueryClient.mdx' - 'toss/suspensive:docs/suspensive.org/src/content/en/docs/react-query/QueriesHydration.mdx' --- This skill builds on react-query. Read ../SKILL.md first. Two experimental APIs cover queries in SSR: `createGetQueryClient` manages the QueryClient lifecycle (new instance per server request, singleton in the browser), and `QueriesHydration` — an async Server Component — prefetches an array of queries on the server and hydrates them into client components, replacing manual QueryClient + prefetchQuery + dehydrate + Hydrate boilerplate (TSQ v4's Hydrate is what v5 renamed to HydrationBoundary; QueriesHydration wraps it internally). Both interfaces are experimental and may change. On the server, `getQueryClient` forces the cache removal time (`cacheTime` in v4 — renamed `gcTime` in v5) to `Infinity` — automatic and non-overridable — so caches survive until the request completes and no `setTimeout` GC scheduling piles up under concurrent requests. The browser instance uses your configured value. ## Setup ```tsx // app/get-query-client.ts import { createGetQueryClient } from '@suspensive/react-query-4' export const { getQueryClient } = createGetQueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, }, }, }) ``` ```tsx // app/providers.tsx 'use client' import { QueryClientProvider } from '@tanstack/react-query' import { getQueryClient } from './get-query-client' import type { ReactNode } from 'react' export const Providers = ({ children }: { children: ReactNode }) => { const queryClient = getQueryClient() return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> } ``` ```tsx // app/layout.tsx import { Providers } from './providers' import type { ReactNode } from 'react' export default function RootLayout({ children }: { children: ReactNode }) { return ( <html lang="en"> <body> <Providers>{children}</Providers> </body> </html> ) } ``` ## Core Patterns ### Per-boundary streaming with QueriesHydration QueriesHydration is an async RSC — render it in a Server Component only. Give each section its own Suspense + QueriesHydration so each streams independently as its queries resolve. ```tsx // app/posts/page.tsx — Server Component (no 'use client') import { Suspense } from '@suspensive/react' import { QueriesHydration } from '@suspensive/react-query-4' import { postsQueryOptions, userQueryOptions } from './queries' import { PostList, UserProfile } from './_components' export default function PostsPage({ userId }: { userId: number }) { return ( <> <Suspense fallback={<div>Loading user...</div>}> <QueriesHydration queries={[userQueryOptions(userId)]}> <UserProfile userId={userId} /> </QueriesHydration> </Suspense> <Suspense fallback={<div>Loading posts...</div>}> <QueriesHydration queries={[postsQueryOptions(userId)]}> <PostList userId={userId} /> </QueriesHydration> </Suspense> </> ) } ``` ```tsx // app/posts/_components/UserProfile.tsx 'use client' import { useSuspenseQuery } from '@tanstack/react-query' import { userQueryOptions } from '../queries' export const UserProfile = ({ userId }: { userId: number }) => { // Data prefetched on the server is hydrated — no client refetch const { data: user } = useSuspenseQuery(userQueryOptions(userId)) return ( <div> <h1>{user.name}</h1> <p>{user.email}</p> </div> ) } ``` `queries` accepts a mix of `queryOptions` and `infiniteQueryOptions` results; every entry must have a `queryKey`. All queries in one array are fetched in parallel with `Promise.all`. ### skipSsrOnError: let the browser take over when server fetch fails Fetching happens at up to 3 stages: (1) RSC in QueriesHydration, (2) RCC on the server via useSuspenseQuery, (3) RCC in the browser. If stage 1 fails (for example cookies/auth unavailable on the server), stage 2 usually fails identically — but the browser can succeed. - `true` (default): on stage-1 error, skip SSR and fetch in the browser - `{ fallback: ReactNode }`: same, but render a custom fallback until the browser takes over - `false`: proceed to stage 2 without hydration (retry the fetch during server rendering of the client component) ```tsx import { Suspense } from '@suspensive/react' import { QueriesHydration } from '@suspensive/react-query-4' import { postsQueryOptions, userQueryOptions } from './queries' import { PostList, UserProfile } from './_components' export default function Page({ userId }: { userId: number }) { return ( <> <Suspense fallback={<div>Loading...</div>}> <QueriesHydration queries={[userQueryOptions(userId)]}> <UserProfile userId={userId} /> </QueriesHydration> </Suspense> <Suspense fallback={<div>Loading...</div>}> <QueriesHydration queries={[postsQueryOptions(userId)]} skipSsrOnError={{ fallback: <div>Unable to fetch on server...</div> }} > <PostList userId={userId} /> </QueriesHydration> </Suspense> </> ) } ``` ### timeout: bound server fetch time ```tsx <Suspense fallback={<div>Loading...</div>}> <QueriesHydration queries={[userQueryOptions(userId)]} timeout={1000}> <UserProfile userId={userId} /> </QueriesHydration> </Suspense> ``` If the queries exceed `timeout` ms on the server, an error is thrown; combined with skipSsrOnError `true`/`{ fallback }`, the server gives up and the browser renders instead. No timeout applies when unset. ### Dependent queries sharing one queryClient ```tsx import { QueryClient } from '@tanstack/react-query' import { Suspense } from '@suspensive/react' import { QueriesHydration } from '@suspensive/react-query-4' import { productQueryOptions, relatedProductsQueryOptions } from './queries' import { ProductInfo, RelatedProducts } from './_components' export default async function ProductPage({ productId }: { productId: string }) { const queryClient = new QueryClient() // created inside the request — never module-level const product = await queryClient.ensureQueryData(productQueryOptions(productId)) return ( <> <Suspense fallback={<div>Loading product...</div>}> <QueriesHydration queryClient={queryClient} queries={[productQueryOptions(productId)]}> <ProductInfo productId={productId} /> </QueriesHydration> </Suspense> <Suspense fallback={<div>Loading related...</div>}> <QueriesHydration queryClient={queryClient} queries={[relatedProductsQueryOptions(product.categoryId)]}> <RelatedProducts categoryId={product.categoryId} /> </QueriesHydration> </Suspense> </> ) } ``` ### QueryClientConsumer: read the QueryClient in JSX (client components) `QueryClientConsumer` (experimental) exposes useQueryClient as a render prop for list rows and conditionals. On v4 it takes an optional `context` prop — TSQ v4's custom QueryClient context — where v5 takes a `queryClient` prop instead (the codemod `npx @suspensive/codemods migrate-query-client-consumer-props .` renames it when you upgrade). ```tsx 'use client' import { QueryClientConsumer } from '@suspensive/react-query-4' export const RefreshButton = () => ( <QueryClientConsumer> {(queryClient) => ( <button onClick={() => queryClient.invalidateQueries({ queryKey: ['posts'] })}>refresh posts</button> )} </QueryClientConsumer> ) ``` ## Common Mistakes ### [CRITICAL] Module-level global QueryClient on the server Wrong: ```tsx // query-client.ts import { QueryClient } from '@tanstack/react-query' export const queryClient = new QueryClient() // shared across all requests ``` Correct: ```tsx // get-query-client.ts import { createGetQueryClient } from '@suspensive/react-query-4' export const { getQueryClient } = createGetQueryClient() // call getQueryClient() where needed; never store the result in a global ``` A module-level QueryClient is shared across server requests, leaking one user's cached data (auth tokens, personal data) into another user's response — createGetQueryClient returns a new instance per server request and a singleton only in the browser. Source: docs/suspensive.org/src/content/en/docs/react-query/createGetQueryClient.mdx ### [CRITICAL] Storing getQueryClient() result in a module global Wrong: ```tsx import { getQueryClient } from './get-query-client' export const queryClient = getQueryClient() // reintroduces cross-request sharing ``` Correct: ```tsx import { getQueryClient } from './get-query-client' const Component = () => { const queryClient = getQueryClient() return null } ``` Exporting the call result freezes one instance at module-evaluation time and shares it across requests — exactly the leak the factory exists to prevent; call getQueryClient() at each use site. Source: docs/suspensive.org/src/content/en/docs/react-query/createGetQueryClient.mdx ### [HIGH] Passing config to getQueryClient instead of createGetQueryClient Wrong: ```tsx const queryClient = getQueryClient({ defaultOptions: { queries: { staleTime: 60_000 } }, }) ``` Correct: ```tsx export const { getQueryClient } = createGetQueryClient({ defaultOptions: { queries: { staleTime: 60_000 } }, }) ``` getQueryClient takes no arguments — the browser singleton must be config-stable across calls — so defaults belong on createGetQueryClient(config). Source: docs/suspensive.org/src/content/en/docs/react-query/createGetQueryClient.mdx ### [HIGH] Using v5 gcTime instead of v4 cacheTime Wrong: ```tsx export const { getQueryClient } = createGetQueryClient({ defaultOptions: { queries: { gcTime: 5 * 60 * 1000 } }, }) ``` Correct: ```tsx export const { getQueryClient } = createGetQueryClient({ defaultOptions: { queries: { cacheTime: 5 * 60 * 1000 } }, }) ``` TSQ v4 names the cache removal time `cacheTime`; `gcTime` is the v5 rename and does not exist in v4 option types. On the server, createGetQueryClient overrides it to Infinity regardless of what you pass. Source: packages/react-query-4/src/createGetQueryClient.ts ### [HIGH] Using QueriesHydration outside an async RSC Wrong: ```tsx 'use client' import { QueriesHydration } from '@suspensive/react-query-4' export const Posts = ({ userId }: { userId: number }) => ( <QueriesHydration queries={[postsQueryOptions(userId)]}> <PostList userId={userId} /> </QueriesHydration> ) ``` Correct: ```tsx // app/posts/page.tsx — Server Component import { QueriesHydration } from '@suspensive/react-query-4' export default function PostsPage({ userId }: { userId: number }) { return ( <QueriesHydration queries={[postsQueryOptions(userId)]}> <PostList userId={userId} /> </QueriesHydration> ) } ``` QueriesHydration is an async Server Component; it only works in RSC frameworks (Next.js 13+ App Router) and cannot render in client components or the pages router. Source: docs/suspensive.org/src/content/en/docs/react-query/QueriesHydration.mdx ### [MEDIUM] Manual prefetch/dehydrate boilerplate in every RSC Wrong: ```tsx const queryClient = new QueryClient() await queryClient.prefetchQuery(postQueryOptions(id)) await queryClient.prefetchQuery(commentsQueryOptions(id)) return <Hydrate state={dehydrate(queryClient)}>{children}</Hydrate> ``` Correct: ```tsx <QueriesHydration queries={[postQueryOptions(id), commentsQueryOptions(id)]}>{children}</QueriesHydration> ``` QueriesHydration automates client creation, parallel prefetch, dehydrate, and hydration (v4's Hydrate internally) in one component, mixes regular and infinite queries, and adds skipSsrOnError/timeout for free. Source: docs/suspensive.org/src/content/en/docs/react-query/QueriesHydration.mdx ### [MEDIUM] Passing queryClient prop to QueryClientConsumer on v4 Wrong: ```tsx <QueryClientConsumer queryClient={queryClient}> {(client) => <button onClick={() => client.invalidateQueries({ queryKey: ['posts'] })}>refresh</button>} </QueryClientConsumer> ``` Correct: ```tsx <QueryClientConsumer context={queryClientContext}> {(client) => <button onClick={() => client.invalidateQueries({ queryKey: ['posts'] })}>refresh</button>} </QueryClientConsumer> ``` On @suspensive/react-query-4 the component mirrors TSQ v4's useQueryClient({ context }) and takes an optional `context` prop; the `queryClient` prop only exists on @suspensive/react-query-5. The codemod `npx @suspensive/codemods migrate-query-client-consumer-props .` renames context → queryClient when upgrading to v5. Source: packages/react-query-4/src/QueryClientConsumer.tsx ## Tensions ### HIGH Tension: getting-started simplicity vs server security A module-level `new QueryClient()` is the simplest setup and perfectly correct in a pure SPA — but copied into an SSR app it becomes a cross-request data leak. When code moves from CSR to Next.js App Router, converting the global client to `createGetQueryClient` is mandatory, not optional cleanup. Never silence this by "it works in dev" — the leak only manifests under concurrent production requests. ### HIGH Tension: clientOnly simplicity vs SSR value Skipping SSR with `<Suspense clientOnly>` (see @suspensive/react's skills/react/ssr-client-only skill) is the easiest fix for hydration problems, but it discards streaming SSR and server prefetch that QueriesHydration exists to provide. Conversely, do not build a QueriesHydration pipeline for a widget that should simply be client-only: a clientOnly boundary removes the need for QueriesHydration under it, because no server prefetch happens there. ```tsx // Server Component: one section client-only, one server-prefetched <Suspense clientOnly fallback={<div>Loading user...</div>}> <UserProfile userId={userId} /> </Suspense> <Suspense fallback={<div>Loading posts...</div>}> <QueriesHydration queries={[postsQueryOptions(userId)]}> <PostList userId={userId} /> </QueriesHydration> </Suspense> ``` See also: ../prefetching/SKILL.md for client-render prefetching, ../declarative-queries/SKILL.md for the client components consuming hydrated data, @suspensive/react skills/react/ssr-client-only for clientOnly and ClientOnly/useIsClient.