SSR & testing
Because a query is plain effector under the hood, fork() + allSettled() work as usual — no special test utilities.
Testing a query
import { fork, allSettled } from 'effector';
const scope = fork();
await allSettled(query.start, { scope, params: 1 });
expect(scope.getState(query.$data)).toEqual(/* ... */);SSR
const scope = fork();
await allSettled(query.start, { scope, params: req.params });
const html = renderToString(/* app */, scope);
const serialized = serialize(scope); // effector serialize — $data / $status / …Bindings are scope-aware: React via <Provider value={scope}>, Vue via the EffectorScopePlugin({ scope }).
Isolating the cache per request ($queryCache)
By default a query's cache adapter is module-level — shared by every scope. For multi-tenant SSR set $queryCache per fork: every query in that scope reads/writes an isolated adapter, so concurrent requests can never see each other's data:
import { $queryCache, inMemoryCache, dehydrate, hydrate } from 'effector-refetch';
// server — one adapter per request
const cache = inMemoryCache();
const scope = fork({ values: [[$queryCache, cache]] });
await allSettled(todosQuery.start, { scope });
const payload = { values: serialize(scope), cache: dehydrate(cache) };
// client
const clientCache = inMemoryCache();
hydrate(clientCache, payload.cache); // storedAt preserved → staleAfter ages correctly
const clientScope = fork({ values: payload.values }); // $data restored — no loading flash
await allSettled($queryCache, { scope: clientScope, params: clientCache }); // stores are callable
// cached keys now hit instead of refetchingNo effector babel/SWC plugin needed for either layer. The public stores ($data / $status / $error / $params / …) carry explicit stable sids (er/<name>/$data), so serialize(scope) picks them up even though the library ships prebuilt (bundler plugins never process node_modules); internal machinery stores are serialize: 'ignore'. Cache entries are namespaced the same way — name ?? the effect's sid ?? a creation counter. Give queries stable names when the server and client bundles may initialize modules in a different order (sids and cache namespaces both follow the name). $queryCache is excluded from serialize(scope) automatically.
Only adapters that can enumerate entries (e.g. inMemoryCache) are dehydratable; web-storage adapters already persist themselves. Without $queryCache everything works as before — the per-query adapter is used (fine for a single-client app). Barriers remain global by design.
Persisting on the client
Two complementary ways to keep data across reloads in the browser:
Cache layer — use
localStorageCache/sessionStorageCacheas the adapter; the query cache survives reloads (andversionlets you invalidate old data).Store layer — persist
$datadirectly witheffector-storage:tsimport { persist } from 'effector-storage/local'; persist({ store: todosQuery.$data as StoreWritable<Todo[] | null>, key: 'todos:data' });(
$datais read-only in the public type but writable at runtime — cast forpersist.)
Full runnable flow: examples/ssr.ts.
Notes
- Sourced config (
Storeforconcurrency/retry.times/cache.staleAfter/enabled) is fork-correct — each scope sees its own value. - Cache isolation for SSR: set
$queryCacheper fork (above). Without it, cache adapters hold module-level state shared across scopes. - In-flight
AbortControllers are tracked per query instance; avoid sharing one query instance across concurrent SSR requests if you also callcancel.