Skip to content

SSR

SSR works by transferring two layers of state from server to client:

  1. QueryClient cache — handled by dehydrate(queryClient) on the server, <HydrationBoundary state={...}> on the client.
  2. 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 unmounted after the response is built: the per-request queryClient and scope are 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 singleton QueryClient into 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 first useQuery, 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
})
  • Migrating from @tanstack/react-query — run both libraries side-by-side on a shared QueryClient while you migrate components route-by-route, with full SSR (<QueryClientCompatProvider> + vanilla <HydrationBoundary>).