# effector-refetch — full documentation
Source: https://olovyannikov.github.io/effector-refetch • https://github.com/Olovyannikov/effector-refetch
Generated from the English docs. Sections: guide, api, recipes.
================================================================================
# Core concepts
URL: https://olovyannikov.github.io/effector-refetch/guide/concepts.html
================================================================================
# Core concepts
## Effect-first
A query wraps your **real effector `Effect`**. It is not a black box with an internal
executor — your effect is a first-class unit: visible in devtools, composable with
`attach`, and fork-friendly for SSR and tests.
```ts
const query = createQuery({ effect: myFx });
query.__.effect === myFx; // the real effect
```
## Lifecycle & status
`$status` moves through `'initial' → 'pending' → 'done' | 'fail'`. A query exposes:
| store/event | meaning |
| ------------------------------ | ---------------------------------------------------- |
| `$data` | latest (validated, mapped) result |
| `$error` | latest error |
| `$status` | `initial \| pending \| done \| fail` |
| `$pending` | request (or retry) in flight |
| `$stale` | current data is past `staleAfter` |
| `$params` | last params the query ran with |
| `finished.{done,fail,finally}` | lifecycle events |
| `aborted` | a result was discarded (concurrency / cancel / skip) |
## Triggers
- `start(params)` — run, honoring cache / concurrency / enabled.
- `refresh(params)` / `refetch(params)` — run, bypassing cache freshness.
- `reset()` — back to initial, abort in-flight.
- `cancel()` — abort in-flight, keep data.
## The engine
Internally the query is a graph of plain effector units (`createStore` / `createEvent`
/ `sample`). All machinery (concurrency, retry, cache) lives in the engine and is
configured by **operators**; the inline `createQuery` options are sugar over them.
Config values can be constants or reactive `Store`s — see [sourced config](/api/queries#sourced-reactive-config).
## SSR & tests
Because it's plain effector, `fork()` + `allSettled()` work as usual — no special
test utilities. See [SSR & testing](/recipes/ssr-and-testing).
================================================================================
# Local development
URL: https://olovyannikov.github.io/effector-refetch/guide/contributing.html
================================================================================
# Local development
Want to hack on **effector-refetch** itself (not just use it)? Here's how to get the repo running.
## Prerequisites
- **Node ≥ 22.13** (required by the pinned pnpm).
- **pnpm 11.5.1** — the repo pins it via `packageManager`. The easiest way is Corepack:
```bash
corepack enable
```
## Clone & install
```bash
git clone https://github.com/Olovyannikov/effector-refetch.git
cd effector-refetch
pnpm install
```
## Scripts
| script | what it does |
| -------------------- | ------------------------------------------------------- |
| `pnpm build` | Build the library (`dist/`) — all entries + `.d.ts` |
| `pnpm typecheck` | `tsc --noEmit` |
| `pnpm lint` | ESLint (incl. `eslint-plugin-effector`) |
| `pnpm format` | Prettier write (`format:check` to verify only) |
| `pnpm test` | Run the Vitest suite once (`test:watch` for watch) |
| `pnpm test:coverage` | Run tests with v8 coverage (thresholds enforced) |
| `pnpm size` | Check bundle budgets (size-limit) |
| `pnpm attw` | Build + check published types (`@arethetypeswrong/cli`) |
| `pnpm docs:dev` | Run this documentation site locally |
| `pnpm docs:build` | Build the docs (also checks for dead links) |
| `pnpm changeset` | Record a changeset for your change (drives releases) |
## Making a change
1. Branch off `main`.
2. Make the change with a test (the suite runs under `fork`/`allSettled` for scope-safety).
3. Run the gate: `pnpm typecheck && pnpm lint && pnpm format:check && pnpm test:coverage && pnpm build && pnpm attw && pnpm size`.
4. Add a changeset: `pnpm changeset` (pick `patch`/`minor`/`major` and write a line — it becomes
the changelog entry).
5. Open a PR. On merge to `main`, CI opens a "Version Packages" PR; merging that publishes to npm.
## Git hooks
`pnpm install` installs [lefthook](https://lefthook.dev) hooks (via the `prepare` script), which run
the same checks as CI before they reach the remote:
| hook | runs |
| ------------ | ----------------------------------------------------------------------------------------------------- |
| `pre-commit` | Prettier (auto-fixes & re-stages staged files), ESLint, `tsc --noEmit` |
| `commit-msg` | [commitlint](https://commitlint.js.org) — [Conventional Commits](https://www.conventionalcommits.org) |
| `pre-push` | `pnpm test:coverage` (the full suite + coverage thresholds) |
Commit messages follow `type(scope): subject` (`feat` / `fix` / `docs` / `style` / `refactor` /
`perf` / `test` / `build` / `ci` / `chore`). Bypass in a pinch with `git commit --no-verify`.
## Trying it against an app
Run the examples directly from source (no build step) with [tsx](https://github.com/privatenumber/tsx):
```bash
npx tsx examples/graphql.ts
```
Or `pnpm build` and `pnpm pack` to get a tarball you can `pnpm add` into another project.
## PR previews & canary builds
Every pull request gets two automatic stands:
- **Docs preview** — the site is built and uploaded as a downloadable workflow **artifact**
(`docs-preview-pr-
` | | [`concurrency`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/operators.ts#L17) | function | `concurrency(query, opts): Q` | | [`connectQuery`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/connect-query.ts#L34) | function | `connectQuery(config): void` | | [`createBarrier`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/barrier.ts#L36) | function | `createBarrier(config): Barrier` | | [`createContract`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/validation.ts#L32) | function | `createContract(c): Contract` | | [`createInfiniteQuery`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/infinite-query.ts#L120) | function | `createInfiniteQuery(config): InfiniteQuery` | | [`createNetworkBarrier`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/browser.ts#L71) | function | `createNetworkBarrier(): NetworkBarrier` | | [`createQuery`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/create-query.ts#L12) | function | `createQuery(config): Query ` | | [`createQueryFactory`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/factory.ts#L53) | function | `createQueryFactory(defaults): QueryFactory` | | [`createRequestFx`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/request.ts#L85) | function | `createRequestFx(handler, options): AbortableEffect ` | | [`isHttpError`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/request.ts#L41) | function | `isHttpError(e, status): boolean` | | [`isRequestError`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/request.ts#L29) | function | `isRequestError(e): boolean` | | [`isTimeoutError`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/request.ts#L48) | function | `isTimeoutError(e): boolean` | | [`isTrigger`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/trigger.ts#L27) | function | `isTrigger(value): boolean` | | [`isValidationError`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/validation.ts#L27) | function | `isValidationError(e): boolean` | | [`keepFresh`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/operators.ts#L103) | function | `keepFresh(query, config): Q` | | [`linearDelay`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/retry.ts#L16) | function | `linearDelay(base, opts): DelayFn` | | [`localStorageCache`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/cache.ts#L161) | function | `localStorageCache(options): CacheAdapter` | | [`normalizeRequestError`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/request.ts#L52) | function | `normalizeRequestError(err): RequestError` | | [`optimisticUpdate`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/update.ts#L88) | function | `optimisticUpdate(config): void` | | [`refetchOnReconnect`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/browser.ts#L47) | function | `refetchOnReconnect(query, scope): () => void` | | [`refetchOnWindowFocus`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/browser.ts#L42) | function | `refetchOnWindowFocus(query, scope): () => void` | | [`retry`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/operators.ts#L30) | function | `retry(query, opts): Q` | | [`runtypesContract`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/validation.ts#L91) | function | `runtypesContract(rt): Contract ` | | [`sessionStorageCache`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/cache.ts#L165) | function | `sessionStorageCache(options): CacheAdapter` | | [`setQueryData`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/cache-access.ts#L19) | function | `setQueryData(query, updater): void` | | [`stableStringify`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/utils.ts#L37) | function | `stableStringify(value): string` | | [`standardSchemaContract`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/validation.ts#L72) | function | `standardSchemaContract(schema): Contract ` | | [`timeout`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/operators.ts#L83) | function | `timeout(query, ms): Q` | | [`update`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/update.ts#L37) | function | `update(config): void` | | [`voidCache`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/cache.ts#L170) | function | `voidCache(): CacheAdapter` | | [`zodContract`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/validation.ts#L50) | function | `zodContract(schema): Contract ` | | [`HTTP_METHODS`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/json-query.ts#L15) | const | | | [`RequestError`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/request.ts#L14) | class | | | [`ValidationError`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/validation.ts#L15) | class | | | [`AbortableEffect`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L14) | type | `AbortableEffect(params): Promise ` | | [`AttachToRouteConfig`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/router.ts#L12) | interface | | | [`Barrier`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/barrier.ts#L3) | interface | | | [`CacheAdapter`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L44) | interface | | | [`CacheConfig`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L57) | interface | | | [`CacheEntry`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L39) | interface | | | [`CombinedQueries`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/combine-queries.ts#L7) | interface | | | [`ConcurrencyStrategy`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L7) | type | | | [`Contract`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/validation.ts#L6) | interface | | | [`CreateBarrierConfig`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/barrier.ts#L16) | interface | | | [`CreateInfiniteQueryConfig`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/infinite-query.ts#L42) | interface | | | [`CreateInfiniteQueryHandlerConfig`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/infinite-query.ts#L50) | interface | | | [`CreateJsonMutationConfig`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/json-query.ts#L225) | interface | | | [`CreateJsonQueryConfig`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/json-query.ts#L45) | interface | | | [`CreateMutationConfig`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L260) | interface | | | [`CreateMutationHandlerConfig`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L279) | interface | | | [`CreateQueryConfig`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L72) | interface | | | [`CreateQueryHandlerConfig`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L118) | interface | | | [`CreateRequestFxOptions`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/request.ts#L67) | interface | | | [`DehydratedEntry`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/cache.ts#L69) | interface | | | [`DelayFn`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L26) | type | `DelayFn(attempt): number` | | [`ErrorOf`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L338) | type | | | [`GetNextPageParamCtx`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/infinite-query.ts#L14) | interface | | | [`GetPreviousPageParamCtx`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/infinite-query.ts#L21) | interface | | | [`HttpMethod`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/json-query.ts#L22) | type | | | [`InfiniteQuery`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/infinite-query.ts#L58) | interface | | | [`InvalidateConfig`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/invalidate.ts#L17) | interface | | | [`InvalidatePayload`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/factory.ts#L30) | interface | | | [`JsonRequest`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/json-query.ts#L37) | interface | | | [`Mutation`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L298) | interface | | | [`MutationUnitShape`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L286) | type | | | [`NetworkBarrier`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/browser.ts#L51) | interface | | | [`OptimisticUpdateConfig`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/update.ts#L63) | interface | | | [`ParamsOf`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L334) | type | | | [`Patchable`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/update.ts#L8) | type | | | [`Query`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L203) | interface | | | [`QueryEffect`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L21) | type | `QueryEffect(params): Promise ` | | [`QueryFactory`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/factory.ts#L35) | interface | | | [`QueryFactoryDefaults`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/factory.ts#L18) | interface | | | [`QueryFinished`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L166) | interface | | | [`QueryInspect`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L143) | interface | | | [`QueryLogEntry`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/inspect.ts#L13) | interface | | | [`QueryLoggerOptions`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/inspect.ts#L23) | interface | | | [`QueryLogType`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/inspect.ts#L3) | type | | | [`QueryStatus`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L5) | type | | | [`QueryUnitShape`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L187) | type | | | [`RequestContext`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/request.ts#L4) | interface | | | [`ResultOf`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L336) | type | | | [`RetryConfig`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L28) | interface | | | [`RouteLike`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/router.ts#L7) | interface | | | [`Sourced`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/json-query.ts#L32) | type | | | [`Trigger`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/trigger.ts#L18) | interface | | | [`UpdateFromEvent`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/update.ts#L24) | interface | | | [`UpdateFromOperation`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/update.ts#L15) | interface | | ## `effector-refetch/react` | Export | Kind | Signature | | --- | --- | --- | | [`useQuery`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/react.ts#L38) | function | `useQuery(query, options): UseQueryResult ` | | [`useSuspenseQuery`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/react.ts#L105) | function | `useSuspenseQuery(query, args): Mapped` | | [`UseQueryOptions`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L342) | interface | | | [`UseQueryResult`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/react.ts#L10) | interface | | ## `effector-refetch/vue` | Export | Kind | Signature | | --- | --- | --- | | [`useQuery`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/vue.ts#L30) | function | `useQuery(query, options): UseQueryVueResult ` | | [`UseQueryOptions`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/types.ts#L342) | interface | | | [`UseQueryVueResult`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/vue.ts#L7) | interface | | ## `effector-refetch/solid` | Export | Kind | Signature | | --- | --- | --- | | [`useQuery`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/solid.ts#L37) | function | `useQuery(query, options): UseQuerySolidResult error.status === 401 }, }); // on a 401, lock the barrier — this kicks off refreshTokenFx sample({ clock: getProfileFx.failData, filter: (error) => error.status === 401, target: authBarrier.lock, }); ``` What happens on a stale token: 1. `getProfileFx` fails with `401` → the barrier **locks** and `refreshTokenFx` runs. 2. The `retry` schedules a re-run — but it **waits at the barrier**. 3. Other queries started meanwhile also queue. 4. `refreshTokenFx` settles → the barrier **unlocks** → the retry (and the queue) run with the fresh token. ## API ```ts const barrier = createBarrier({ perform?: Effect }); barrier.lock(); // close — gated queries wait barrier.unlock(); // open — queued queries proceed barrier.$locked; // Store ``` With `perform`, locking auto-runs the effect and unlocks when it settles (success **or** failure — no deadlock). Without it, drive `lock`/`unlock` yourself. Gate a single query without a factory — via the config option, or the `applyBarrier` operator on an already-created query/mutation (pass `null` to detach): ```ts const q = createQuery({ effect: fx, barrier: authBarrier }); // or, after creation: applyBarrier(existingQuery, authBarrier); ``` ::: warning Client-side The barrier reads the no-scope store, so it's meant for a single running app, not per-`fork` isolation. (Request pausing rarely applies during SSR.) ::: ================================================================================ # Auto-refetch & polling URL: https://olovyannikov.github.io/effector-refetch/recipes/auto-refetch.html ================================================================================ # Auto-refetch & polling ## Polling — `refetchInterval` Refetch on a timer while the query is started and enabled: ```ts const stats = createQuery({ effect: fetchStatsFx, refetchInterval: 5000 }); // every 5s stats.start(); ``` After each settle (success or failure) the query waits `refetchInterval` ms, then `refresh`es with the last params (bypassing cache). It's paused while `$enabled` is `false`, stops on `reset`, and is fork-correct (each scope polls independently). The interval can be reactive — pass a `Store ` and change it live (e.g. faster while a tab is active): ```ts createQuery({ effect: fx, refetchInterval: $pollMs }); ``` ## On window focus / reconnect Opt-in, browser-only, tree-shakeable: ```ts import { refetchOnWindowFocus, refetchOnReconnect } from 'effector-refetch'; const stop1 = refetchOnWindowFocus(userQuery); const stop2 = refetchOnReconnect(userQuery); // call stop1() / stop2() to detach ``` Both refetch with the query's last params, only if it has run and is enabled. They read the no-scope store, so they're meant for a single-client app; for scoped apps, drive `query.refetch` yourself with `scopeBind`. ## Offline / network mode `createNetworkBarrier()` is a [barrier](/recipes/auth-barrier) that **locks while the browser is offline** and unlocks on reconnect. Gate queries with it and their runs pause when the connection drops, then resume automatically when it returns — no per-query wiring: ```ts import { createNetworkBarrier, refetchOnReconnect } from 'effector-refetch'; const offline = createNetworkBarrier(); const userQuery = createQuery({ effect: fetchUserFx, barrier: offline }); // or apply it to a whole group: createQueryFactory({ barrier: offline }) offline.$online; // Store — drive an "offline" banner refetchOnReconnect(userQuery); // optional: also refresh already-loaded data offline.stop(); // detach the online/offline listeners on teardown ``` A run started while offline sits in `pending` (the effect body isn't entered) until the network returns. Browser-only — on the server the barrier stays open (online). ## Refetch on source change — `keepFresh` Keep a query fresh relative to external state (filters, locale, viewer): `keepFresh` refetches it with its **last params** whenever a `source` store changes. ```ts import { keepFresh } from 'effector-refetch'; keepFresh(productsQuery, { source: $filters }); // or source: [$filters, $locale] ``` No-op until the query has run (`status !== 'initial'`) and while it's disabled. Distinct from `refetchInterval` (time-based) — this is dependency-based. (If the source value should actually change the request, thread it through the params instead — `connectQuery` / `sample` into `start`.) ## Web-API triggers (`@withease/web-api`) [`@withease/web-api`](https://withease.effector.dev/web-api/) trackers implement the `@@trigger` protocol, so they drop **straight** into `keepFresh({ triggers })` — no adapter: ```ts import { trackPageVisibility, trackNetworkStatus } from '@withease/web-api'; import { keepFresh } from 'effector-refetch'; const network = trackNetworkStatus(); // refetch when the tab becomes visible again (tracker fires its `visible` event) keepFresh(dashboardQuery, { triggers: [trackPageVisibility()] }); // …or on any plain event the tracker exposes keepFresh(dashboardQuery, { triggers: [network.online] }); ``` Tracker **stores** fit the other slots: gate a query while offline with `enabled`, or treat a tracker's store as a `source`: ```ts createQuery({ effect: fx, enabled: network.$online }); ``` This overlaps the built-in `refetchOnWindowFocus` / `refetchOnReconnect` / `createNetworkBarrier` above — use the built-ins for those common cases, and reach for `@withease/web-api` when you want more signals (geolocation, media query, screen orientation, …) behind the same `@@trigger` API. ## Compose with patronum A query's triggers are plain effector events, so you can drive them with any [patronum](https://patronum.effector.dev/operators/) operator — no special API needed: ```ts import { interval, debounce, throttle } from 'patronum'; // debounced search-as-you-type debounce({ source: queryChanged, timeout: 300, target: searchQuery.start }); // custom polling with start/stop control const { tick } = interval({ timeout: 10_000, start: pageOpened, stop: pageClosed }); sample({ clock: tick, source: searchQuery.$params, target: searchQuery.refetch }); // throttle a refresh button throttle({ source: refreshClicked, timeout: 1000, target: dashboard.refresh }); ``` Use the built-in `refetchInterval` for the common case; reach for patronum when you want explicit start/stop, debounce or throttle semantics. ================================================================================ # Circuit breaker URL: https://olovyannikov.github.io/effector-refetch/recipes/circuit-breaker.html ================================================================================ # Circuit breaker A [barrier](/recipes/auth-barrier) is a mutex you can `lock` and `unlock`; gated queries wait while it's locked. That's all you need for a **circuit breaker**: after N consecutive failures, trip the breaker so requests stop hammering a failing backend, wait out a cooldown, then let a trial wave through — a fresh failure re-opens it, a success closes it. No new API — it's a barrier plus a failure counter. ```ts import { createEffect, createStore, sample } from 'effector'; import { createBarrier, applyBarrier } from 'effector-refetch'; const THRESHOLD = 3; // consecutive failures that trip the breaker const COOLDOWN = 10_000; // ms the circuit stays "open" // The "open" window: locking runs the cooldown effect, and the barrier re-opens // automatically when it settles (createBarrier unlocks on `perform.finally`). const cooldownFx = createEffect(() => new Promise ((r) => setTimeout(r, COOLDOWN))); const breaker = createBarrier({ perform: cooldownFx }); // Count consecutive failures; any success resets to 0 (→ closed). const $failures = createStore(0) .on(api.finished.fail, (n) => n + 1) .reset(api.finished.done); // Trip when the threshold is reached. A re-lock while already open is a no-op // (the store value doesn't change), so the cooldown isn't restarted mid-window. sample({ clock: $failures.updates, filter: (n) => n >= THRESHOLD, target: breaker.lock }); applyBarrier(api, breaker); // or: createQuery({ effect, barrier: breaker }) ``` ## How it behaves - **Closed** — `$failures < THRESHOLD`, requests run normally. - **Open** — the threshold trips `breaker.lock`; gated runs **pause** (they `await` the barrier), so a struggling backend stops getting hammered. `cooldownFx` runs for `COOLDOWN`, then the barrier unlocks. - **Half-open** — the paused request(s) resume. Because `$failures` is still `≥ THRESHOLD`, a single fresh failure immediately re-trips the breaker (another cooldown); the first **success** resets the counter and closes it. `breaker.$locked` is the "open" flag — bind it with `useUnit` to show a "service unavailable, retrying in a moment" banner. ::: tip Pause vs fail-fast A textbook breaker _fails fast_ while open; this one **pauses** requests until the cooldown elapses, then retries them. For a query layer that's usually what you want (no error flash, automatic recovery). If you need fail-fast, sample `breaker.$locked` into a guard that rejects instead. ::: ## Across several queries Share one breaker over a group — feed it the merged failures and gate each query: ```ts import { merge } from 'effector'; const anyFailure = merge([usersQuery.finished.fail, ordersQuery.finished.fail]); const $failures = createStore(0) .on(anyFailure, (n) => n + 1) .reset([usersQuery.finished.done, ordersQuery.finished.done]); [usersQuery, ordersQuery].forEach((q) => applyBarrier(q, breaker)); ``` Half-open releases every queued request together (fine for the typical one-in-flight-per-query case); for a strict single-trial half-open, add your own gate that lets just one request through after the cooldown. Runnable: [`examples/circuit-breaker.ts`](https://github.com/Olovyannikov/effector-refetch/blob/main/examples/circuit-breaker.ts). ================================================================================ # Shared defaults (query factory) URL: https://olovyannikov.github.io/effector-refetch/recipes/defaults.html ================================================================================ # Shared defaults (query factory) effector-refetch has no global `QueryClient`. Instead, bake shared policy into a factory with `createQueryFactory` — per-call options always override the defaults. ```ts import { createQueryFactory } from 'effector-refetch'; const { createQuery, createMutation } = createQueryFactory({ retry: 2, cache: { staleAfter: 30_000 }, concurrency: 'TAKE_LATEST', }); const todos = createQuery({ effect: fetchTodosFx }); // retry 2 + cache by default const search = createQuery({ effect: searchFx, retry: 0 }); // override: no retry ``` ## Make every query poll The motivating case — one place to give all queries a polling interval: ```ts const { createQuery } = createQueryFactory({ refetchInterval: 30_000 }); const stats = createQuery({ effect: fetchStatsFx }); // polls every 30s const feed = createQuery({ effect: fetchFeedFx, refetchInterval: 5_000 }); // override to 5s ``` See the runnable [`examples/polling.ts`](https://github.com/Olovyannikov/effector-refetch/blob/main/examples/polling.ts). ## What a factory carries Query defaults: `retry`, `cache`, `concurrency`, `refetchInterval`, `structuralSharing`, `enabled`, `debug`. Mutations only inherit `retry`, `concurrency`, `debug` (cache / polling don't apply to writes). Need different policies per area (e.g. `shared/api` vs `internal/api`)? Just create multiple factories. ::: tip Why not a global client? effector is decentralized — a god-object `QueryClient` fights that model. A factory gives you the same "defaults in one place" ergonomics while every query stays a plain, testable effector unit. ::: ================================================================================ # Dependent queries URL: https://olovyannikov.github.io/effector-refetch/recipes/dependent-queries.html ================================================================================ # Dependent queries When one request needs the result of another (fetch a character, then its origin location), [`connectQuery`](/api/queries) wires them: when the **source** succeeds it computes the **target**'s params and starts it. No `sample` boilerplate, no effect in a component. ## One source ```ts import { createQuery, connectQuery } from 'effector-refetch'; const characterQuery = createQuery({ effect: fetchCharacterFx, cache: true }); const originQuery = createQuery({ effect: fetchLocationFx, cache: { staleAfter: 60_000 } }); connectQuery({ source: characterQuery, fn: ({ result: character }) => ({ params: { url: character.origin.url } }), target: originQuery, }); characterQuery.start(1); // originQuery starts automatically when the character resolves ``` `fn` receives `{ result, params }` of the source (the result, plus the params it ran with) and returns `{ params }` for the target. It re-runs every time the source succeeds, so the target stays in sync — and since both are plain queries, `cache` / `retry` / `concurrency` apply to each independently. ## Several sources Pass an object of queries — the target starts once **all** of them are `done`, then again whenever any of them produces a fresh result: ```ts connectQuery({ source: { user: userQuery, settings: settingsQuery }, fn: ({ user, settings }) => ({ params: { id: user.result.id, theme: settings.result.theme } }), target: dashboardQuery, }); ``` Each key in `fn`'s argument is `{ result, params }` for that source. ## Gating with `filter` `filter` skips the target run when the source result isn't usable yet (same `{ result, params }` shape, return `false` to skip): ```ts connectQuery({ source: characterQuery, filter: ({ result: character }) => character.origin.url !== '', // unknown origin -> don't fetch fn: ({ result: character }) => ({ params: { url: character.origin.url } }), target: originQuery, }); ``` ## `connectQuery` vs `combineQueries` - **`connectQuery`** — _chains_: a source feeds the next query's **params** (request B depends on A's result). - [**`combineQueries`**](/api/queries) — _aggregates_: read several independent queries as one combined `$data` / `$pending` / `$errors` (the effector-flavored `useQueries`), with no dependency between them. Runnable: [`examples/rick-and-morty.ts`](https://github.com/Olovyannikov/effector-refetch/blob/main/examples/rick-and-morty.ts). ================================================================================ # Error handling URL: https://olovyannikov.github.io/effector-refetch/recipes/error-handling.html ================================================================================ # Error handling Errors are first-class: a failed effect drives `$error`, flips `$status` to `'fail'`, and emits `finished.fail` — all scope-safe. This recipe covers reading errors, normalizing them, deciding what to retry, and reacting globally. ## Reading the error ```ts const userQuery = createQuery({ effect: fetchUserFx }); userQuery.$error; // Store userQuery.$status; // 'initial' | 'pending' | 'done' | 'fail' userQuery.finished.fail; // Event<{ params; error }> ``` In a component, the bindings expose it directly: ```ts // React const { error, status } = useQuery(userQuery); // Vue const { error, isFail } = useQuery(userQuery); ``` ## Normalizing errors with `mapError` Turn raw failures into a shape your UI understands before they ever reach `$error`: ```ts const userQuery = createQuery({ effect: fetchUserFx, mapError: ({ error, params }) => ({ code: (error as RequestError).status ?? 0, message: error instanceof Error ? error.message : 'Unknown error', userId: params, }), }); // userQuery.$error is now Store<{ code; message; userId } | null> ``` ## Typed transport errors `createRequestFx` rejects with a [`RequestError`](/api/http) carrying `status` and `data`, so you can branch on the HTTP status. Wrapping a third-party client? `normalizeRequestError` coerces axios/ofetch-style errors into the same shape: ```ts import { createRequestFx, RequestError, normalizeRequestError } from 'effector-refetch'; const fetchUserFx = createRequestFx(async (id: number, { signal }) => { const res = await fetch(`/api/users/${id}`, { signal }); if (!res.ok) throw new RequestError(`HTTP ${res.status}`, { status: res.status, data: await res.text() }); return res.json(); }); // from axios/ofetch: const fetchFx = createRequestFx(async (id: number) => { try { return (await api.get(`/users/${id}`)).data; } catch (e) { throw normalizeRequestError(e); // -> RequestError { status, data } } }); ``` ## Type guards Instead of `instanceof` + `.status` casts, narrow errors with the built-in guards: ```ts import { isRequestError, isHttpError, isTimeoutError, isValidationError } from 'effector-refetch'; const message = (e: unknown) => { if (isHttpError(e, 401)) return 'Please sign in'; if (isHttpError(e, (s) => s >= 500)) return 'Server error — try again'; if (isTimeoutError(e)) return 'Timed out'; if (isValidationError(e)) return `Bad data: ${e.validationErrors.join(', ')}`; if (isRequestError(e)) return e.message; // network error (no status) return 'Unknown error'; }; ``` - `isHttpError(e, status?)` — a `RequestError` with a `status`; pass a code (`404`) or a predicate (`(s) => s >= 500`). - `isTimeoutError(e)` — a run aborted by [`timeout`](/api/queries). - `isValidationError(e)` — a failed [contract / `validate`](/api/http) (narrows to `.validationErrors`). - `isRequestError(e)` — any normalized transport error. ## Deciding what to retry By default `retry` repeats on any failure. Use `filter` to retry only the transient ones (skip 4xx), and `suppressIntermediateErrors` to keep `$error` clean until the final attempt: ```ts import { isHttpError, isTimeoutError } from 'effector-refetch'; const query = createQuery({ effect: fetchUserFx, retry: { times: 3, delay: (attempt) => 2 ** attempt * 200, // backoff // network errors, timeouts, and 5xx — never 4xx filter: ({ error }) => isTimeoutError(error) || isHttpError(error, (s) => s >= 500) || !isHttpError(error), suppressIntermediateErrors: true, // $error stays null while retrying }, }); ``` A failed validation ([contracts](/api/http)) throws a `ValidationError`, which flows through the same path and **is retryable** — handy when a flaky upstream occasionally returns malformed data. ## Reacting globally `finished.fail` is a plain effector event — `sample` it into a toast, a logger, or Sentry: ```ts import { sample } from 'effector'; sample({ clock: [userQuery.finished.fail, todosQuery.finished.fail], fn: ({ error }) => (error instanceof Error ? error.message : 'Request failed'), target: showToastFx, }); ``` With a [shared factory](/recipes/defaults) you can wire this once for every query in a group via its `finished.fail` events, instead of repeating it per query. ## 401 → refresh → replay For "the token expired, refresh it and replay the failed requests", don't handle it per query — pause the whole environment with a [barrier](/recipes/auth-barrier): on a 401 it locks, refreshes the token, then releases the queued requests. Runnable error shapes: [`examples/graphql.ts`](https://github.com/Olovyannikov/effector-refetch/blob/main/examples/graphql.ts) (GraphQL `errors` → `RequestError`) and the [HTTP page](/api/http). ================================================================================ # File uploads (FormData) URL: https://olovyannikov.github.io/effector-refetch/recipes/file-uploads.html ================================================================================ # File uploads (FormData) A file upload is just a `POST` with a `FormData` body — so it needs no special support. Build the `FormData` inside a [`createRequestFx`](/api/http) effect and hand it to `createMutation`. ```ts import { createRequestFx, createMutation } from 'effector-refetch'; interface UploadResult { url: string; } const uploadFx = createRequestFx<{ file: Blob; name: string; tags: string[] }, UploadResult>( ({ file, name, tags }, { signal }) => { const body = new FormData(); body.append('file', file, 'upload.txt'); body.append('name', name); tags.forEach((tag) => body.append('tags', tag)); // repeated field = array return fetch('/api/upload', { method: 'POST', body, signal }).then((r) => r.json()); }, ); export const uploadMutation = createMutation({ effect: uploadFx, retry: 1 }); uploadMutation.mutate({ file, name: 'avatar', tags: ['a', 'b'] }); ``` ::: warning Don't set `Content-Type` Leave it off — the runtime sets `multipart/form-data` **with the boundary** automatically. Setting it by hand breaks the request. ::: Everything else is a normal mutation: `$pending` drives a spinner, `$error` is a normalized `RequestError`, `retry` re-uploads, and `cancel()` / `reset()` abort the in-flight request via the `signal` (so the upload actually stops). ## Upload progress `fetch` can't report **upload** progress, so for a progress bar wrap `XMLHttpRequest` instead — it's still just an effect. Push progress into a store and honor the abort `signal`: ```ts import { createStore, createEvent } from 'effector'; import { createRequestFx, createMutation } from 'effector-refetch'; const progress = createEvent (); // 0..1 export const $uploadProgress = createStore(0).on(progress, (_, p) => p); const uploadFx = createRequestFx<{ file: File }, { url: string }>( ({ file }, { signal }) => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const body = new FormData(); body.append('file', file); xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) progress(e.loaded / e.total); }); xhr.addEventListener('load', () => xhr.status < 400 ? resolve(JSON.parse(xhr.responseText)) : reject(new Error(`HTTP ${xhr.status}`)), ); xhr.addEventListener('error', () => reject(new Error('Network error'))); // the query owns the signal — cancel()/reset()/TAKE_LATEST abort the upload signal.addEventListener('abort', () => xhr.abort()); xhr.open('POST', '/api/upload'); xhr.send(body); }), ); export const uploadMutation = createMutation({ effect: uploadFx }); ``` `progress` is a plain event, so `$uploadProgress` is just a store — bind it with `useUnit` like any other state. Reset it on `uploadMutation.finished.finally` if you want it to clear after each upload. Pair uploads with [`invalidate`](/api/mutations) (refresh a gallery after a successful upload) or [`optimisticUpdate`](/recipes/optimistic) (show the new item immediately). Runnable: [`examples/form-data.ts`](https://github.com/Olovyannikov/effector-refetch/blob/main/examples/form-data.ts). ================================================================================ # GraphQL URL: https://olovyannikov.github.io/effector-refetch/recipes/graphql.html ================================================================================ # GraphQL GraphQL is just a `POST` with `{ query, variables }`, so it needs no special support — wrap one document in a [`createRequestFx`](/api/http) effect and hand it to `createQuery` / `createMutation`. A tiny factory keeps the endpoint, headers, abort signal, and error handling in one place. ## A reusable client factory ```ts import { createRequestFx, RequestError } from 'effector-refetch'; const ENDPOINT = 'https://countries.trevorblades.com/graphql'; interface GraphqlResponse { data?: Data; errors?: Array<{ message: string }>; } /** One GraphQL document -> an Abortable effect that takes its variables. */ export function graphql>(document: string) { return createRequestFx (async (variables, { signal }) => { const res = await fetch(ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query: document, variables }), signal, }); const json = (await res.json()) as GraphqlResponse; if (json.errors?.length) { throw new RequestError(json.errors[0].message, { status: res.status, data: json.errors }); } return json.data as Data; }); } ``` Turning GraphQL `errors` into a `RequestError` is the important bit: now `retry`, `$error`, and the inspector treat a GraphQL-level failure exactly like an HTTP one. ## A query ```ts import { createQuery } from 'effector-refetch'; const getCountriesFx = graphql<{ countries: Country[] }, { continent: string }>(` query Countries($continent: String!) { countries(filter: { continent: { eq: $continent } }) { code name emoji } } `); const countriesQuery = createQuery({ effect: getCountriesFx, cache: { staleAfter: 60_000 } }); countriesQuery.start({ continent: 'EU' }); ``` ## A mutation The same factory powers writes — just pass the effect to `createMutation`: ```ts import { createMutation } from 'effector-refetch'; const addReviewFx = graphql<{ addReview: { id: string } }, { code: string; stars: number }>(` mutation AddReview($code: ID!, $stars: Int!) { addReview(code: $code, stars: $stars) { id } } `); const addReviewMutation = createMutation({ effect: addReviewFx, retry: 1 }); addReviewMutation.start({ code: 'EU', stars: 5 }); ``` Because variables _are_ the effect's params, [`connectQuery`](/api/queries) and [`combineQueries`](/api/queries) compose GraphQL operations just like REST ones. Runnable: [`examples/graphql.ts`](https://github.com/Olovyannikov/effector-refetch/blob/main/examples/graphql.ts). ================================================================================ # Groups & cache access URL: https://olovyannikov.github.io/effector-refetch/recipes/groups-and-cache.html ================================================================================ # Groups & cache access ## Invalidate a group of queries A [factory](/recipes/defaults) registers every query it creates, so you can refetch them together — the effector-flavored equivalent of `queryClient.invalidateQueries`: ```ts import { createQueryFactory } from 'effector-refetch'; const { createQuery, invalidate } = createQueryFactory(); const todos = createQuery({ effect: fetchTodosFx }); const profile = createQuery({ effect: fetchProfileFx }); // refetch every query that has run invalidate(); // or narrow with a predicate (receives the query object) invalidate({ predicate: (q) => q === todos }); ``` `invalidate` is a plain event — scope-correct (`allSettled(invalidate, { scope })` for SSR/tests) and composable: `sample({ clock: loggedOut, target: invalidate })`. Only queries that have already run are refetched, with their last params. Need separate groups? Use multiple factories. ## Read & write cache imperatively ```ts import { getQueryData, setQueryData } from 'effector-refetch'; const todos = getQueryData(todosQuery); // current $data setQueryData(todosQuery, (prev) => [...(prev ?? []), newTodo]); // value or (prev) => next ``` These read/write the no-scope store — for a single client app. In scoped code read `scope.getState(query.$data)`, and prefer the [`update`](/api/mutations#update) / [`optimisticUpdate`](/recipes/optimistic) operators (they're scope-correct). ## gcTime? TanStack's `gcTime` evicts cache entries once they have no observers. effector-refetch doesn't track observers (queries are plain units, not subscriptions), so the closest knob is age-based eviction on the adapter: `inMemoryCache({ maxAge })` / `localStorageCache({ maxAge })`, plus `maxEntries` for an LRU cap. ================================================================================ # effector inspector & logging URL: https://olovyannikov.github.io/effector-refetch/recipes/inspector.html ================================================================================ # effector inspector & logging Beyond the [visual devtools](/api/devtools), effector-refetch plays well with the broader effector tooling — the inspector and any logger. ## Name your queries Give each query a `name` (or `debug: true`) so its units show up labelled in the inspector: ```ts const todos = createQuery({ effect: fetchTodosFx, name: 'todos' }); // units: todos.start, todos.$data, todos.$status, todos.runFx, todos.inspect.* const adhoc = createQuery({ effect: fx, debug: true }); // labelled as query.* without a name ``` ## @effector/inspector ```ts import { inspect } from '@effector/inspector'; inspect(); // renders the inspector; named query units are grouped and readable ``` ## Custom logging with attachQueryLogger For headless logging (server logs, analytics, your own panel), subscribe to the lifecycle stream: ```ts import { attachQueryLogger } from 'effector-refetch'; attachQueryLogger(todos, { name: 'todos', handler: (entry) => logger.debug('query', entry), // entry: { query, type, params?, attempt?, error?, durationMs? } }); ``` `type` is one of `start | run | done | fail | aborted | cache-hit | cache-miss | retry`. Forward these into Sentry breadcrumbs, a custom timeline, or `console`. ## Low-level stream If you need the raw effector events (e.g. to wire your own `sample`), use `query.__.inspect` — see [Introspection](/api/introspection). ================================================================================ # Normalized list updates URL: https://olovyannikov.github.io/effector-refetch/recipes/list-updates.html ================================================================================ # Normalized list updates When a mutation changes one item in a list query, you usually don't need a full refetch — patch the item in place with [`update`](/api/mutations#update) (or `optimisticUpdate`). ## Patch an item by id ```ts import { update } from 'effector-refetch'; // todosQuery.$data: Todo[] update({ query: todosQuery, on: toggleTodoMutation, // returns the updated Todo fn: ({ data, result: updated }) => (data ?? []).map((todo) => (todo.id === updated.id ? updated : todo)), }); ``` ## Remove an item ```ts update({ query: todosQuery, on: deleteTodoMutation, // params: id fn: ({ data, params: id }) => (data ?? []).filter((todo) => todo.id !== id), }); ``` ## Optimistic toggle with rollback ```ts import { optimisticUpdate } from 'effector-refetch'; optimisticUpdate({ query: todosQuery, on: toggleTodoMutation, update: ({ data, params: id }) => (data ?? []).map((t) => (t.id === id ? { ...t, done: !t.done } : t)), // on success, reconcile with the server's version commit: ({ data, result: updated }) => (data ?? []).map((t) => (t.id === updated.id ? updated : t)), }); ``` ## Paginated lists (`createInfiniteQuery`) `update` / `optimisticUpdate` accept an infinite query too — there `data` is the **array of pages**, so map over the pages and patch the item in place (no refetch, no flicker): ```ts update({ query: todosInfinite, // createInfiniteQuery(...) on: toggleTodoMutation, fn: ({ data: pages, result: id }) => (pages ?? []).map((page) => ({ ...page, items: page.items.map((t) => (t.id === id ? { ...t, done: !t.done } : t)), })), }); ``` The same shape works with `optimisticUpdate` (its `update`/`commit` callbacks also receive the page array). Patches go through the query's `__.setData` seam, so they're scope-correct. ================================================================================ # Optimistic updates URL: https://olovyannikov.github.io/effector-refetch/recipes/optimistic.html ================================================================================ # Optimistic updates Show a change instantly, roll back on failure, reconcile with the server on success — then optionally `invalidate` to confirm against server truth (the TanStack pattern). ```ts import { createQuery, createMutation, optimisticUpdate, invalidate } from 'effector-refetch'; const todosQuery = createQuery({ effect: fetchTodosFx }); const addTodo = createMutation({ effect: addTodoFx }); optimisticUpdate({ query: todosQuery, on: addTodo, // applied immediately on addTodo.mutate(...) update: ({ data, params }) => [{ id: -1, text: params.text, pending: true }, ...(data ?? [])], // reconcile the temp item with the server result on success commit: ({ data, result }) => (data ?? []).map((t) => (t.id === -1 ? result : t)), // rollbackOnFailure defaults to true }); // reconcile against server truth as well invalidate({ on: addTodo, refetch: todosQuery }); addTodo.mutate({ text: 'Buy milk' }); ``` ## How it works - On `addTodo` **start**, the previous `$data` is snapshotted and the optimistic value applied. - On **failure**, `$data` is rolled back to the snapshot. - On **success**, `commit` reconciles (or the optimistic value is kept if no `commit`). ::: warning Optimistic updates assume effectively-serial mutations per query (the common case). Heavily interleaved concurrent mutations on the same query can clobber each other's rollback snapshot. ::: ================================================================================ # Router & loaders URL: https://olovyannikov.github.io/effector-refetch/recipes/router.html ================================================================================ # Router & loaders With a data router (React Router 6.4+, TanStack Router, …) you can fetch in the route **loader**, so a page renders with its data already present — no in-component loading flash. effector-refetch fits because a query is plain effector: the loader drives it through your scope, the component reads it with `useUnit`. ## React Router loader ```tsx import { allSettled, fork } from 'effector'; import { useUnit } from 'effector-react'; import { createBrowserRouter } from 'react-router-dom'; const userQuery = createQuery({ effect: fetchUserFx, cache: { staleAfter: 30_000 } }); const scope = fork(); // the same scope you render under const router = createBrowserRouter([ { path: '/users/:id', // run the query and wait for it before the route renders loader: async ({ params }) => { await allSettled(userQuery.start, { scope, params: Number(params.id) }); return null; // data lives in userQuery.$data, not the loader result }, Component: () => { const { data, pending, error } = useUnit(userQuery); if (error) return Failed
; return{pending ? 'Loading…' : data?.name}
; // pending only on a cache miss }, }, ]); ``` - **`cache`** makes revisits instant — the loader resolves from cache with no network call. - **SSR**: build a fresh `scope` per request, run the loaders, then `serialize(scope)` → `fork({ values })` on the client (see [SSR & testing](/recipes/ssr-and-testing)). - **No scope** (plain SPA): in the loader, `userQuery.start(id)` and `await` the query's `finished.finally` once, instead of `allSettled`. The same shape works for TanStack Router's `loader`, or any framework that fetches before render. Runnable: [`examples/react-router.tsx`](https://github.com/Olovyannikov/effector-refetch/blob/main/examples/react-router.tsx). ## atomic-router For effector's own router, `attachToRoute` is the glue: start the query when the route **opens** (with its params) and reset it when the route **closes** — no component effect. ```ts import { createRoute } from 'atomic-router'; import { attachToRoute } from 'effector-refetch'; const userRoute = createRoute<{ id: string }>(); attachToRoute({ route: userRoute, query: userQuery, mapParams: ({ params }) => Number(params.id), // route params → query params // resetOnClose: true (default) }); ``` It's structural (atomic-router isn't imported — any object with `opened`/`closed` works) and pure `sample` under the hood, so it's scope-correct for SSR. `mapParams` is optional when the route params already match the query's. Runnable: [`examples/atomic-router.ts`](https://github.com/Olovyannikov/effector-refetch/blob/main/examples/atomic-router.ts). ================================================================================ # Selecting slices URL: https://olovyannikov.github.io/effector-refetch/recipes/select.html ================================================================================ # Selecting slices Sometimes a component needs only a **slice** of a query's data and should re-render only when that slice changes (TanStack calls this `select`). There's nothing to add — a query's `$data` is a plain effector store, so the native primitives already do it. ::: tip select vs `mapData` `mapData` reshapes the data for the **whole query** (every consumer sees the mapped value). A slice is **per-consumer**: each component derives its own view without touching the query. Reach for `mapData` when the shape is global, for a slice when it's local. ::: ## In a component — `useStoreMap` Every binding ships `useStoreMap`: it subscribes to a derived value and only updates when that value actually changes. ```ts // React — effector-react import { useStoreMap } from 'effector-react'; const name = useStoreMap(userQuery.$data, (u) => u?.name ?? ''); // Solid — effector-solid (returns an accessor — call it: name()) import { useStoreMap } from 'effector-solid'; const name = useStoreMap(userQuery.$data, (u) => u?.name ?? ''); ``` ```ts // Vue — effector-vue/composition (config form; returns a ComputedRef — name.value) import { useStoreMap } from 'effector-vue/composition'; const name = useStoreMap({ store: userQuery.$data, fn: (u) => u?.name ?? '' }); ``` Need params in the selector key (e.g. pick one item by id)? Pass `keys`: `useStoreMap({ store, keys: [id], fn: (list, [id]) => list.find((x) => x.id === id) })` (React/Solid take the short `useStoreMap(store, fn)` form too). ## Headless / model code Outside a component, derive a store once with `.map` — it updates only when the slice changes: ```ts const $userName = userQuery.$data.map((u) => u?.name ?? null); ``` This is the building block the bindings use; create it at module scope (not per render) and subscribe to it anywhere. ================================================================================ # Shared request factory URL: https://olovyannikov.github.io/effector-refetch/recipes/shared-factory.html ================================================================================ # Shared request factory Bake `baseURL` + headers/auth into a factory once, then declare endpoints in one line each — the FSD `shared/api` pattern. ```ts import { ofetch } from 'ofetch'; import { createRequestFx, createQuery, createMutation, concurrency, invalidate } from 'effector-refetch'; const HTTP_METHODS = { GET: 'GET', POST: 'POST', PUT: 'PUT', DELETE: 'DELETE' } as const; function createRequestFactory(opts: { baseURL: string; headers?: () => Record}) { return ( descriptor: (params: Params) => { url: string; method?: string; params?: Record ; body?: unknown; }, ) => createRequestFx ((params, { signal }) => { const { url, method = 'GET', params: query, body } = descriptor(params); return ofetch (url, { baseURL: opts.baseURL, method, query, body, headers: opts.headers?.(), signal, }); }); } let apiKey = ''; export const createCommonRequestFx = createRequestFactory({ baseURL: API_URL, headers: () => ({ 'X-API-KEY': apiKey }), }); export const createInternalRequestFx = createRequestFactory({ baseURL: API_URL, headers: () => ({ Authorization: `Bearer ${token}` }), }); ``` Endpoints: ```ts export const getProductsQuery = createQuery({ effect: createCommonRequestFx ((params) => ({ url: '/products', params })), cache: { staleAfter: 30_000 }, }); concurrency(getProductsQuery, { strategy: 'TAKE_LATEST' }); export const likeProductMutation = createMutation({ effect: createInternalRequestFx ((id) => ({ url: `/products/${id}/likes`, method: HTTP_METHODS.PUT, })), }); invalidate({ on: likeProductMutation, refetch: getProductsQuery }); ``` Full runnable version: [`examples/shared-factory.ts`](https://github.com/Olovyannikov/effector-refetch/blob/main/examples/shared-factory.ts). ================================================================================ # SSR & testing URL: https://olovyannikov.github.io/effector-refetch/recipes/ssr-and-testing.html ================================================================================ # 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 ` `, Vue via the `EffectorScopePlugin({ scope })`. ### Transferring the cache (`dehydrate` / `hydrate`) `serialize(scope)` captures the **store state**, but the query **cache** (dedupe / `staleAfter`) lives outside the scope, so it isn't included. `dehydrate` snapshots it; `hydrate` restores it on the client — so cached params hit instead of refetching: ```ts // server — alongside serialize(scope) const cache = inMemoryCache(); const todos = createQuery({ effect: fetchTodosFx, cache: { adapter: cache } }); // … run queries under the scope … const payload = { values: serialize(scope), cache: dehydrate(cache) }; // client hydrate(cache, payload.cache); // warm the cache (storedAt preserved → staleAfter ages correctly) const scope = fork({ values: payload.values }); // $data restored — no loading flash ``` Only adapters that can enumerate entries (e.g. `inMemoryCache`) are dehydratable; web-storage adapters already persist themselves. ### 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`](https://github.com/yumauri/effector-storage): ```ts import { persist } from 'effector-storage/local'; persist({ store: todosQuery.$data as StoreWritable , key: 'todos:data' }); ``` (`$data` is read-only in the public type but writable at runtime — cast for `persist`.) Full runnable flow: [`examples/ssr.ts`](https://github.com/Olovyannikov/effector-refetch/blob/main/examples/ssr.ts). ## Notes - Sourced config (`Store` for `concurrency` / `retry.times` / `cache.staleAfter` / `enabled`) is **fork-correct** — each scope sees its own value. - Cache adapters hold state outside the effector scope; for isolated SSR build queries per request (as usual), or pass a fresh adapter. - In-flight `AbortController`s are tracked per query _instance_; avoid sharing one query instance across concurrent SSR requests if you also call `cancel`. ================================================================================ # Streaming (SSE & WebSocket) URL: https://olovyannikov.github.io/effector-refetch/recipes/streaming.html ================================================================================ # Streaming (SSE & WebSocket) A query owns the **snapshot**; a stream keeps a live **view store** fresh. Load the initial data with a query, then fold stream events into a plain store seeded from the query's public `finished.done`. One raw stream message is fanned out into typed events with [patronum's `splitMap`](https://patronum.effector.dev/operators/split-map/) — no query internals, no refetch. ::: tip Why a separate store? The query stays the single owner of fetching; the view store owns the _merged_ live state. This avoids reaching into private seams and keeps the stream logic plain effector you can test and compose. ::: ## Server-Sent Events ```ts import { createEffect, createEvent, createStore, sample } from 'effector'; import { splitMap } from 'patronum'; import { createQuery } from 'effector-refetch'; const noticesQuery = createQuery({ effect: fetchNoticesFx }); // raw SSE message -> typed events const messageReceived = createEvent (); const { created, deleted } = splitMap({ source: messageReceived, cases: { created: (m) => (m.event === 'created' ? m.notice : undefined), deleted: (m) => (m.event === 'deleted' ? m.id : undefined), }, }); // view store: seeded from the query, then patched by the stream const $notices = createStore ([]) .on(created, (list, notice) => [notice, ...list]) .on(deleted, (list, id) => list.filter((n) => n.id !== id)); sample({ clock: noticesQuery.finished.done, fn: ({ result }) => result, target: $notices }); const openStreamFx = createEffect((url: string) => { const source = new EventSource(url); source.addEventListener('message', (e) => messageReceived(JSON.parse(e.data))); return source; // source.close() to stop }); noticesQuery.start(); openStreamFx('/api/notices/stream'); ``` ## WebSocket ```ts const pricesQuery = createQuery({ effect: fetchPricesFx }); const messageReceived = createEvent<{ type: string; payload: unknown }>(); const { snapshot, tick } = splitMap({ source: messageReceived, cases: { snapshot: (m) => (m.type === 'snapshot' ? (m.payload as Prices) : undefined), tick: (m) => (m.type === 'tick' ? (m.payload as Tick) : undefined), }, }); const $prices = createStore ({}) .on(snapshot, (_prices, snap) => snap) .on(tick, (prices, t) => ({ ...prices, [t.symbol]: t.value })); sample({ clock: pricesQuery.finished.done, fn: ({ result }) => result, target: $prices }); // open a WebSocket, forward messages to messageReceived, track a $connected store… ``` Because the query is plain effector, the socket/SSE lifecycle is just effects and events — compose reconnect, backoff, or other [patronum](https://patronum.effector.dev/) operators freely. Runnable: [`examples/sse.ts`](https://github.com/Olovyannikov/effector-refetch/blob/main/examples/sse.ts), [`examples/websocket.ts`](https://github.com/Olovyannikov/effector-refetch/blob/main/examples/websocket.ts). ================================================================================ # index.md URL: https://olovyannikov.github.io/effector-refetch/ ================================================================================ ## Install ::: code-group ```bash [pnpm] pnpm add effector-refetch effector ``` ```bash [npm] npm install effector-refetch effector ``` ```bash [yarn] yarn add effector-refetch effector ``` ::: Framework bindings are optional peers — add the ones you use: `effector-react` + `react`, `effector-vue` + `vue`, or `effector-solid` + `solid-js`. Full walkthrough in [Getting started](/guide/getting-started). ## A 30-second taste ```ts import { createEffect } from 'effector'; import { createQuery, createMutation, invalidate } from 'effector-refetch'; const fetchTodosFx = createEffect(() => fetch('/api/todos').then((r) => r.json())); const addTodoFx = createEffect((text: string) => fetch('/api/todos', { method: 'POST', body: JSON.stringify({ text }) }).then((r) => r.json()), ); export const todos = createQuery({ effect: fetchTodosFx, cache: true, retry: 2 }); export const addTodo = createMutation({ effect: addTodoFx }); // when a todo is added, refresh the list invalidate({ on: addTodo, refetch: todos }); todos.start(); addTodo.mutate('Buy milk'); // → todos refetches automatically ``` In a component, read it with one hook: ```tsx const { data, pending } = useUnit(todos); // React / Vue / Solid ``` ## Why not just use effects directly? You can — and you still are. effector-refetch doesn't replace your effects; it wires the boring, fiddly parts around them: loading/error status, retries, caching, request cancellation, deduplication, validation. Your effect stays a first-class effector unit you can see in devtools and test with `fork()`. If you've felt the pain of hand-rolling "is it loading, did it fail, is this response stale, did the user click twice, which request won the race" — that's the part this library owns, declaratively. ## When it fits — and when it doesn't **Reach for it** when you have real async with races, caching needs, or many endpoints, and you want it testable without a renderer. **Skip it** for a tiny app with a couple of `useState` calls, or a throwaway prototype — plain effects (or even `fetch`) are honest there. We'd rather you under-use this than cargo-cult it. See the [honest comparison with farfetched](/guide/vs-farfetched). Pre-1.0 and actively developed · MIT · [Roadmap](https://github.com/Olovyannikov/effector-refetch/blob/main/ROADMAP.md)