Skip to content

SSR & testing

Because a query is plain effector under the hood, fork() + allSettled() work as usual — no special test utilities.

Testing a query

ts
import { fork, allSettled } from 'effector';

const scope = fork();
await allSettled(query.start, { scope, params: 1 });
expect(scope.getState(query.$data)).toEqual(/* ... */);

SSR

ts
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:

ts
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 refetching

No 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 / sessionStorageCache as the adapter; the query cache survives reloads (and version lets you invalidate old data).

  • Store layer — persist $data directly with effector-storage:

    ts
    import { persist } from 'effector-storage/local';
    persist({ store: todosQuery.$data as StoreWritable<Todo[] | null>, key: 'todos:data' });

    ($data is read-only in the public type but writable at runtime — cast for persist.)

Full runnable flow: examples/ssr.ts.

Notes

  • Sourced config (Store for concurrency / retry.times / cache.staleAfter / enabled) is fork-correct — each scope sees its own value.
  • Cache isolation for SSR: set $queryCache per 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 call cancel.

MIT Licensed