Migrating from @tanstack/react-query
You don’t have to migrate the whole codebase in one sweep. Both libraries can read and write the same QueryClient cache at the same time — a setQueryData from vanilla useQuery re-renders our useQuery, an invalidate triggered through an effector factory re-renders vanilla components. Migrate routes (or even individual components) when it’s convenient, and let the cache stay the source of truth in between.
This guide covers the SSR-aware setup. If you don’t ship server-rendered pages, the client-only setup is a subset — skip straight to Shared cache, shared key.
Three things that must line up
For the two APIs to share a cache entry instead of accidentally maintaining two:
- One
QueryClientinstance. On the client that’s the browser singleton you built in your top-level provider. On the server it’s the per-requestQueryClientyou built in your page’s server component. - One
queryKey. Byte-identical array shape and values — the very same primitive key is what links the two observers to the same cache row. - Matching defaults —
staleTime,gcTime,retry. Diverging defaults mean one side will refetch while the other thinks the data is still fresh; you’ll see “the new code somehow fetches twice”.
Get those three right and the migration mechanics fall out. Skip any of them and you’ll spend hours wondering why the cache “doesn’t share”.
Shared cache, shared key
import { createQuery } from '@effector-tanstack-query/core'
export const USERS_KEY = ['users'] as const
export function fetchUsers(): Promise<User[]> { return fetch('/api/users').then((r) => r.json())}
// New code — effector factory. queryKey/queryFn/staleTime are the// single source of truth.export const usersQuery = createQuery({ name: 'users', queryKey: USERS_KEY, queryFn: fetchUsers, staleTime: 60_000,})// component.tsx — vanilla side (legacy code, untouched)import { useQuery as useVanillaQuery } from '@tanstack/react-query'import { USERS_KEY, fetchUsers } from '@/model/users'
function LegacyList() { const { data } = useVanillaQuery({ queryKey: USERS_KEY, // ← same key queryFn: fetchUsers, // ← same function staleTime: 60_000, // ← same defaults }) return data?.map(/* … */)}// other-component.tsx — migrated sideimport { useQuery } from '@effector-tanstack-query/react'import { usersQuery } from '@/model/users'
function NewList() { const { data } = useQuery(usersQuery) return data?.map(/* … */)}Both components read from the same cache row. invalidateQueries({ queryKey: USERS_KEY }) from anywhere re-fetches once and pushes new data to both.
Bridging the two scopes
@tanstack/react-query’s hooks look up the QueryClient from React context (<QueryClientProvider>). This library looks it up from the effector scope (useUnit($queryClient)). For a migration setup both lookups have to land on the same instance.
That’s what <QueryClientCompatProvider> does: read $queryClient and hand the same instance to <QueryClientProvider>.
pnpm add @tanstack/react-query# already installed: @effector-tanstack-query/react// migration-area-layout.tsx (client)'use client'
import { QueryClientCompatProvider } from '@effector-tanstack-query/react/compat'
export function MigrationLayout({ children }: { children: React.ReactNode }) { return ( <QueryClientCompatProvider defaultOptions={{ queries: { retry: false, staleTime: 60_000 } }} > {children} </QueryClientCompatProvider> )}@tanstack/react-query is an optional peer dep — non-migrating users don’t have to install it. The compat module is published as a separate subpath (@effector-tanstack-query/react/compat) so its weight is only paid when you import it.
SSR (Next.js App Router)
// app/migration/page.tsx (server)import { dehydrate, HydrationBoundary } from '@tanstack/react-query'import { serialize } from 'effector'import { EffectorNext } from '@effector/next'import { prefetchQueries } from '@effector-tanstack-query/core'import { QueryClientCompatProvider } from '@effector-tanstack-query/react/compat'import { usersQuery } from '@/model/users'import { makeRequestScope } from '@/lib/server'
export default async function MigrationPage() { const { queryClient, scope } = makeRequestScope()
// One prefetch, both sides. Same queryKey → same cache entry. await prefetchQueries([usersQuery], { scope })
return ( <QueryClientCompatProvider defaultOptions={{ queries: { retry: false, staleTime: 60_000 } }} > <HydrationBoundary state={dehydrate(queryClient)} /> <EffectorNext values={serialize(scope)}> <PageBody /> </EffectorNext> </QueryClientCompatProvider> )}A few things to notice:
- Vanilla
<HydrationBoundary>(from@tanstack/react-query) is what hydrates the cache here — not@effector-tanstack-query/react’s<HydrationBoundary>. In a migration setup vanilla covers both sides: on the server it hydrates the per-render fallbackQueryClientfrom<QueryClientCompatProvider>(so vanillauseQuerygets data for SSR HTML), and on the client it hydrates the browser singleton (which$queryClientalso points at, so effector observers see the same data). - Sibling pattern.
<HydrationBoundary>is a side-effect-only component — it callshydrate(qc, state)during render. Rendered as a sibling of<EffectorNext>rather than as a wrapper, so the layered story reads naturally as “snapshot two layers, then render the tree”. - Single prefetch fills both sides.
prefetchQueries([usersQuery], { scope })writes into the per-requestQueryClientAND mounts the effector observer (populates$data).dehydrate(queryClient)carries the cache snapshot;serialize(scope)carries the effector store snapshot. Both ride the RSC payload to the client.
QueryClientCompatProvider
import { QueryClientCompatProvider } from '@effector-tanstack-query/react/compat'
interface QueryClientCompatProviderProps { children: React.ReactNode /** * `defaultOptions` for the per-render server fallback `QueryClient`. * Has no effect on the client — there it forwards the singleton built * in your top-level provider. */ defaultOptions?: DefaultOptions}Internally:
-
Client.
useUnit($queryClient)returns the singleton browserQueryClient(set by your top-level provider viasetQueryClient(qc)+allSettled($queryClient, { params: qc, scope })). Hands that exact instance to<QueryClientProvider>. VanillauseQueryand ouruseQuerynow see the same cache. -
Server.
$queryClientisserialize: 'ignore'(instances can’t ride throughserialize(scope), and per-request QCs must not become a module-level singleton on the server — that would leak data between concurrent requests). SouseUnit($queryClient)resolves tonullinside the RSC rendering scope. To keep vanillauseQueryworking during the server pass, the provider creates a throwaway per-renderQueryClientviauseState(() => isServer ? new QueryClient(...) : null). The neighbouring<HydrationBoundary state={dehydrate(...)}>thenhydrates this throwaway with the same snapshot that gets shipped to the client — so server-rendered HTML shows data instead of a loading flash. -
Hydration handover. When the client mounts,
useUnit($queryClient)flips fromnullto the singleton, the provider re-renders with the singleton client, react-query re-subscribes its observers, and from that moment forward both APIs read and write the same browser-sideQueryClient. The throwaway server QC is GCed with the React tree it was created in.
The useState initializer makes the fallback per-component-instance, not module-level — concurrent SSR requests don’t share it.
Pitfalls
- Don’t create a second
QueryClient“just for react-query”. Two clients = two caches.<QueryClientCompatProvider>exists precisely so you don’t have to. - Don’t double-prefetch. One
prefetchQueries(...)(or oneqc.prefetchQuery(...)) fills the shared cache entry — calling both wastes a network round-trip. - Default options must match between sides. Centralise them on the effector factory; copy them into vanilla call-sites byte-for-byte; refactor when they drift.
- Provider order.
<QueryClientProvider>(i.e.<QueryClientCompatProvider>) must be an ancestor of<HydrationBoundary>from@tanstack/react-query— the boundary looks up the QC via context and crashes without a provider. - Module-level singletons on the server are unsafe. It can be tempting to
let qc = qc ?? new QueryClient()at module scope to “share” it with client components. Don’t — that singleton would survive across concurrent requests and leak data between users. The per-render fallback the provider creates is correct and per-request by construction.
Finishing the migration
Once every component for a feature has been moved to useQuery(factory):
- Drop the vanilla
useQuery({ queryKey, queryFn })call sites. - Remove
@tanstack/react-queryfrom that route’s tree — drop the<QueryClientCompatProvider>+ vanilla<HydrationBoundary>pair and replace with<HydrationBoundary>from@effector-tanstack-query/react(sibling, same pattern; noQueryClientProviderneeded). - When no vanilla
useQueryremains anywhere, uninstall@tanstack/react-query—@effector-tanstack-query/reactonly depends on@tanstack/query-core.
A complete working migration playground (both APIs on one cache, with mutation propagation in both directions) lives in examples/ssr/app/migration.