SSR
SSR works by transferring two layers of state from server to client:
QueryClientcache — handled bydehydrate(queryClient)on the server,<HydrationBoundary state={...}>on the client.- Effector scope — handled by
serialize(scope)on the server,fork({ values })(or<EffectorNext values>in Next.js) on the client.
You need both for a clean hydration with no flash and no extra refetch.
Server
Create a fresh QueryClient per request and inject it through the $queryClient store via fork({ values }). Each request gets its own scope-isolated observer — nothing leaks between requests.
Use the prefetchQueries helper to fill both SSR layers in the right order. It runs prefetch (awaits qc.fetchQuery(...) → cache populated) and then mounted (observer’s first dispatch writes the cached data into the effector stores) for every query you hand it. Skipping mounted leaves the stores empty — serialize(scope) then ships nothing useful and the server-rendered HTML lacks data.
import { QueryClient, dehydrate } from '@tanstack/query-core'import { fork, serialize } from 'effector'import { $queryClient, prefetchQueries } from '@effector-tanstack-query/core'
export async function renderPage() { const queryClient = new QueryClient() // queryClient.mount() is intentionally NOT called on the server.
const scope = fork({ values: [[$queryClient, queryClient]] })
// Fills both layers: queryClient cache (via prefetch) AND effector // stores in scope (via mounted). Awaits each phase. await prefetchQueries([userQuery], { scope })
// Snapshot both layers and ship them to the client. return { dehydratedQueryClient: dehydrate(queryClient), serializedScope: serialize(scope), }}No explicit
unmountedafter the response is built: the per-requestqueryClientandscopeare local to the function, and the returned snapshots only carry plain serializable data — nothing keeps the live observers / subscriptions alive after the response is sent.
Client
Wrap your tree in two providers — one per layer:
import { Provider } from 'effector-react'import { QueryClient } from '@tanstack/query-core'import { fork } from 'effector'import { HydrationBoundary } from '@effector-tanstack-query/react'import { $queryClient } from '@effector-tanstack-query/core'
const queryClient = new QueryClient()queryClient.mount()
const scope = fork({ values: { ...serializedScope, // from server [$queryClient.sid!]: queryClient, // inject the client into scope },})
ReactDOM.hydrateRoot( document.getElementById('root'), <Provider value={scope}> <HydrationBoundary state={dehydratedQueryClient} /> <App /> </Provider>,)<HydrationBoundary> reads the QueryClient from the scope (useUnit($queryClient)) and calls hydrate(qc, state) inside useMemo — render-phase side effect. Rendered as a sibling of <App /> rather than a wrapper: React renders sibling children top-to-bottom, so by the time <App /> reaches its first useQuery, the cache is already populated. See the API reference for details.
Next.js (App Router)
@effector/next handles the singleton client scope across App Router navigations. Combine it with <HydrationBoundary> and you get a clean per-page hydration:
// app/layout.tsx (server)import { Providers } from '@/lib/providers'
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <Providers>{children}</Providers> </body> </html> )}// src/lib/providers.tsx (client)'use client'import { allSettled } from 'effector'import { EffectorNext, getClientScope } from '@effector/next'import { QueryClient } from '@tanstack/query-core'import { $queryClient, setQueryClient } from '@effector-tanstack-query/core'
// Top-level await: runs once per browser session, BEFORE any render.// On the server, getClientScope() is null — the block is skipped.if (typeof window !== 'undefined') { const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60_000 } }, }) queryClient.mount() setQueryClient(queryClient) await allSettled($queryClient, { params: queryClient, scope: getClientScope()!, })}
export function Providers({ children }: { children: React.ReactNode }) { return <EffectorNext>{children}</EffectorNext>}// app/page.tsx (server component)import { fork, serialize } from 'effector'import { QueryClient, dehydrate } from '@tanstack/query-core'import { EffectorNext } from '@effector/next'import { HydrationBoundary } from '@effector-tanstack-query/react'import { $queryClient, prefetchQueries } from '@effector-tanstack-query/core'import { listQuery, pokemonQuery } from '@/model/queries'
export default async function Home() { const queryClient = new QueryClient() const scope = fork({ values: [[$queryClient, queryClient]] })
await prefetchQueries([listQuery, pokemonQuery], { scope })
return ( <EffectorNext values={serialize(scope)}> <HydrationBoundary state={dehydrate(queryClient)} /> <PageBody /> </EffectorNext> )}Both <HydrationBoundary> and <EffectorNext> are client components imported directly into the server page — Next handles the RSC boundary, and the dehydrated/serialized props travel through the RSC payload.
How the pieces fit:
- Layout’s
<EffectorNext>owns a singleton client scope, alive for the whole browser session. - Top-level
await allSettled($queryClient, ...)injects the singletonQueryClientinto that scope once, before any render. - Per page, two hydration steps run during render — both as direct children of
<EffectorNext>:<HydrationBoundary state={dehydrate(queryClient)} />(sibling, side effect) merges the server’s query cache into the singleton qc.<EffectorNext values={serialize(scope)}>(wrapper) merges the server’s effector store snapshots into the singleton scope while also providing context to descendants.
- Render order top-to-bottom puts the
hydrate(...)call before<PageBody />’s firstuseQuery, so consumers find a populated cache on the very first paint. - The singleton scope means navigating between routes preserves any client-side effector state (selected filters, accumulators, …); the singleton qc means the cache survives navigation and dedupes across pages.
A complete working example lives in examples/ssr.
Why both layers
If you only do dehydrate(queryClient) + <HydrationBoundary>:
- ✅ Future refetches and invalidations work correctly.
- ❌ But on the very first render the effector stores in the new scope are still empty (until
mounted()propagates a fresh result via the observer subscription) — causes a flash.
If you only do serialize(scope) + fork({ values }):
- ✅ React render hydrates with no flash.
- ❌ But the queryClient cache is empty, so any subsequent fetch / invalidate behaves like a fresh load.
Together they’re equivalent to react-query’s <HydrationBoundary> + <QueryClientProvider> combo.
You must pass name
Effector stores serialize via stable IDs (SIDs). Without a name on createQuery / createMutation / createInfiniteQuery, the internal stores have no SID and are silently dropped from serialize(scope) — your client-side scope receives nothing.
const userQuery = createQuery({ name: 'user', // ← required for SSR-via-scope queryKey: ['user', $userId], queryFn: fetchUser,})A development warning fires the first time you create a query without a name. See Naming & SIDs for the full story.
staleTime considerations
If staleTime: 0 (the default), the observer on the client refetches immediately after hydration even with cached data. To use the SSR-shipped data without an immediate refetch:
createQuery({ name: 'user', queryKey: ['user'], queryFn: fetchUser, staleTime: 60_000, // or Infinity, or whatever your server-side data freshness allows})Related
- Migrating from
@tanstack/react-query— run both libraries side-by-side on a sharedQueryClientwhile you migrate components route-by-route, with full SSR (<QueryClientCompatProvider>+ vanilla<HydrationBoundary>).