# 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-`). _(The production docs own the single GitHub Pages source, so previews aren't a live URL; for that, deploy previews to an external host — see below.)_ - **Canary package** — [pkg.pr.new](https://pkg.pr.new) publishes a preview build you can install straight from the PR: `npm i https://pkg.pr.new/Olovyannikov/effector-refetch@` (no npm pollution). The bot comments the exact command. ## Continuous integration (GitHub Actions) Workflows under `.github/workflows/`: | Workflow | Trigger | What it does | | --------------------- | --------------- | ----------------------------------------------------------------------------- | | `ci.yml` | push / PR | typecheck · lint · format:check · test (coverage) · build · attw · size-limit | | `release.yml` | push to `main` | changesets: opens a "Version Packages" PR; on its merge, publishes to npm | | `docs.yml` | push to `main` | builds the docs and deploys them to GitHub Pages (Actions artifact) | | `pr-preview.yml` | pull_request | builds the PR docs and uploads them as a downloadable artifact | | `pkg-pr-new.yml` | push / PR | publishes a canary via pkg.pr.new | | `release-codemod.yml` | manual dispatch | publishes the `effector-refetch-codemod` package (in `codemod/`) to npm | ### Required repository setup These need to be enabled once by a maintainer (Actions can't configure them itself): 1. **Pages** — Settings → Pages → Source: **GitHub Actions**. (`docs.yml` deploys the built site via the Pages artifact.) 2. **Workflow permissions** — Settings → Actions → General → **Read and write permissions**, and **Allow GitHub Actions to create and approve pull requests** (for the Version Packages PR). 3. **`NPM_TOKEN` secret** — a classic **Automation** token (bypasses 2FA) or a Granular token with read+write on packages. Drives `release.yml` and `release-codemod.yml`. 4. **pkg.pr.new GitHub App** — install [`pkg-pr-new`](https://github.com/apps/pkg-pr-new) on the repo so the canary workflow can publish (it's a no-op / non-blocking until then). Want **live** per-PR doc URLs? Point `pr-preview.yml` at an external host (Cloudflare Pages, Netlify, Vercel) — that needs an account and a token secret, but leaves the production Pages deploy untouched. The docs generators (`scripts/gen-api.mjs`, `scripts/gen-llms.mjs`) run inside `docs:build`, so both `docs.yml` and `pr-preview.yml` always ship a fresh API reference and `llms.txt`. ================================================================================ # Getting started URL: https://olovyannikov.github.io/effector-refetch/guide/getting-started.html ================================================================================ # Getting started This page gets you from zero to a working query in a couple of minutes. For the _why_, read the [Introduction](/guide/introduction) first. `effector-refetch` is a small, friendly query layer for [effector](https://effector.dev), built on **real effects**. The unit of work is your own `Effect` (including `attach`-built factory effects) — the query is just a thin reactive shell. ::: tip Prerequisites You'll want a basic feel for effector (`createEffect`, stores, `sample`). If you're new, skim the [effector docs](https://effector.dev) — everything here is just plain effector units composed for you. ::: ## Install The package is published on npm as **`effector-refetch`** (`effector` is a peer dependency): ::: 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 peer-scoped subpaths — install the peers you use: ::: code-group ```bash [React] pnpm add effector-react react # enables effector-refetch/react + /devtools ``` ```bash [Vue] pnpm add effector-vue vue # enables effector-refetch/vue ``` ::: ## Your first query Hover the identifiers below — these snippets are **type-checked** with [Twoslash](https://twoslash.netlify.app/) against the real types: ```ts twoslash import { createEffect } from 'effector'; import { createQuery } from 'effector-refetch'; interface User { id: number; name: string; } // your effect — fetch / axios / ofetch, anything returning a Promise const fetchUserFx = createEffect(async (id: number): Promise => { return { id, name: 'Ada' }; }); const userQuery = createQuery({ effect: fetchUserFx, retry: 2, cache: true, concurrency: 'TAKE_LATEST', }); userQuery.start(1); // hover userQuery.$data → Store, $status, $pending, … all typed ``` ## Connecting queries ```ts twoslash import { createEffect } from 'effector'; import { createQuery, connectQuery } from 'effector-refetch'; interface Character { origin: { url: string }; } const characterQuery = createQuery({ effect: createEffect(async (id: number): Promise => ({ origin: { url: `/o/${id}` } })), }); const originQuery = createQuery({ effect: createEffect(async (p: { url: string }): Promise<{ name: string }> => ({ name: 'Earth' })), }); // ---cut--- connectQuery({ source: characterQuery, fn: ({ result: character }) => ({ params: { url: character.origin.url } }), target: originQuery, }); ``` When `characterQuery` resolves, `originQuery` starts automatically with the derived params. ## Next - [Core concepts](/guide/concepts) — the effect-first model and lifecycle. - [Queries API](/api/queries) — every option. - [vs. farfetched](/guide/vs-farfetched) — how this compares. ================================================================================ # Introduction URL: https://olovyannikov.github.io/effector-refetch/guide/introduction.html ================================================================================ # Introduction ## The problem Almost every app does the same dance around data: kick off a request, track whether it's loading, catch the error, show the result. Then reality piles on — the user double-clicks, a second request races the first, the response is already stale, you want a retry, you'd like to not refetch what you just fetched, and ideally none of this lives inside a React component. Hand-rolled, that's a swamp of `useState`/`useEffect`, race conditions, and logic welded to the view. effector solves the _state_ part beautifully with events, stores and effects. **effector-refetch** owns the _data-fetching_ part on top of it. ## The idea: build on real effects The core decision is simple: **your effect is the unit of work.** ```ts const fetchUserFx = createEffect((id: number) => api.user(id)); const userQuery = createQuery({ effect: fetchUserFx }); ``` `userQuery` is a thin reactive shell around `fetchUserFx`. It adds `$data`, `$error`, `$status`, `$pending`, lifecycle events, retry, cache and concurrency — but the effect underneath is still your effect: visible in devtools, composable with `attach`, and fork-friendly for SSR and tests. Nothing is hidden in a private executor. That's the difference from a "black box" data layer: you keep effector's mental model (events flow through a graph of rules) and just get the querying conveniences declaratively. ## What you get - **Status & lifecycle** — `$data / $error / $status / $pending`, `finished.{done,fail}`. - **Concurrency** — `TAKE_LATEST` (default), `TAKE_FIRST`, `TAKE_EVERY`; races just die. - **Retry** — graph-level, each attempt a real effect call. - **Caching** — staleAfter, SWR, GC, dedupe, persistence. - **Real cancellation** — abort the in-flight request, not just ignore it. - **Mutations & invalidation** — `createMutation`, `invalidate`, `update`, optimistic updates. - **Validation** — schema contracts (zod / Standard Schema) turn bad responses into errors. - **Declarative HTTP** — `createJsonQuery` over the global `fetch`. - **Pagination** — `createInfiniteQuery` (bidirectional `fetchNext`/`fetchPrevious`). - **Auto-refetch & polling** — `refetchInterval`, window-focus / reconnect helpers. - **Barrier & offline** — `createBarrier` (401 → refresh → replay) and `createNetworkBarrier`. - **Bindings** — `useUnit(query)` / `useQuery` for React, Vue and Solid, plus `useSuspenseQuery`. - **Devtools** — visual panels for React, Vue and Solid + an introspection stream. ## Philosophy - **Friendly by default, powerful underneath.** Inline options cover the 90%; the same features are standalone operators for the rest. - **No magic.** It's plain effector units (`createStore` / `sample` / `createEffect`) you could have written — just composed for you. - **Honest.** We tell you when _not_ to use it, and how it compares to [farfetched](/guide/vs-farfetched). Ready? [Get started →](/guide/getting-started) ================================================================================ # LLMs & AI agents URL: https://olovyannikov.github.io/effector-refetch/guide/llms.html ================================================================================ # LLMs & AI agents effector-refetch ships machine-readable docs and an installable agent skill, so AI coding agents write idiomatic, fork-correct code with the library. ## Agent skill A [Claude Code skill](https://github.com/Olovyannikov/effector-refetch/tree/main/skills) teaching the effect-first API, the bindings, and the fork-correct idioms (plus a "common mistakes" checklist). It works with Claude Code and 70+ other agents (Cursor, Codex, OpenCode, …). ### Install with the `skills` CLI (recommended) Using [vercel-labs/skills](https://github.com/vercel-labs/skills): ```bash # add the effector-refetch skill to the agents in your project npx skills add Olovyannikov/effector-refetch # preview what's in the repo first npx skills add Olovyannikov/effector-refetch --list # target a specific agent, or install globally for all your projects npx skills add Olovyannikov/effector-refetch -a claude-code npx skills add Olovyannikov/effector-refetch -g ``` ### Install manually ```bash # from a project that already has the package installed cp -R node_modules/effector-refetch/skills/effector-refetch .claude/skills/ ``` Or copy [`skills/effector-refetch/SKILL.md`](https://github.com/Olovyannikov/effector-refetch/blob/main/skills/effector-refetch/SKILL.md) into your project's `.claude/skills/` (or `~/.claude/skills/` for all projects). It's plain Markdown with YAML frontmatter — any agent/system-prompt loader can read it. ## llms.txt Following the [llms.txt convention](https://llmstxt.org), the docs site exposes two plain-text files for LLM context: - **[`/llms.txt`](https://olovyannikov.github.io/effector-refetch/llms.txt)** — an index: title, summary, and links to every documentation page. - **[`/llms-full.txt`](https://olovyannikov.github.io/effector-refetch/llms-full.txt)** — the full text of the documentation in one file, ready to paste into a model's context. Point your tool at the URL, or download it: ```bash curl -fsSL https://olovyannikov.github.io/effector-refetch/llms-full.txt -o effector-refetch-docs.txt ``` Both files are regenerated on every docs build from the English docs. ================================================================================ # Migration URL: https://olovyannikov.github.io/effector-refetch/guide/migration.html ================================================================================ # Migration ## Codemod (automated) A codemod handles the mechanical parts — rewriting imports and folding the standalone operators into the inline `createQuery` config: ```bash npx effector-refetch-codemod "src/**/*.{ts,tsx}" npx effector-refetch-codemod "src/**/*.ts" --dry # preview only ``` It rewrites `@farfetched/core` → `effector-refetch`, turns `retry(q, …)` / `cache(q, …)` / `concurrency(q, { strategy })` into `createQuery({ retry, cache, concurrency })`, and drops the now-unused operator imports. Operators on a query it can't resolve statically are left as-is — review the diff and run your formatter after. The manual mapping below covers the rest. ## From farfetched The mental model is close, so most code maps directly. The main shift: **bring your own effect**, and inline options are available alongside operators. | farfetched | effector-refetch | | -------------------------------------- | ------------------------------------------------------------------------------------------------- | | `createQuery({ handler })` | `createQuery({ effect })` (or `{ handler }`) | | `createJsonQuery({ ... })` | `createJsonQuery({ request, response })` | | `createJsonMutation({ ... })` | `createJsonMutation({ request, response })` | | `retry(query, { times, delay })` | `retry(query, …)` **or** inline `createQuery({ retry })` | | `cache(query, { ... })` | `cache(query, …)` **or** inline `createQuery({ cache })` | | `concurrency(query, { strategy })` | `concurrency(query, …)` **or** inline `createQuery({ concurrency })` | | `timeout(query, ms)` | `timeout(query, ms)` **or** inline `createQuery({ timeout })` | | `keepFresh(query, { triggers })` | `keepFresh(query, { source, triggers })` | | `connectQuery({ source, fn, target })` | identical | | `createMutation` | `createMutation` (+ `mutate` alias) | | `createBarrier` / `applyBarrier` | `createBarrier` / `applyBarrier` (or inline `createQuery({ barrier })`) | | `@farfetched/atomic-router` | `attachToRoute({ route, query })` (structural) | | `@@trigger` consumers / producers | every query/mutation implements `@@trigger`; `keepFresh` consumes it | | contracts | `zodContract` / `runtypesContract` / `ioTsContract` / `standardSchemaContract` / `createContract` | | `finished.{success,failure,skip}` | same names (`success`/`failure` alias `done`/`fail`; `skip` on the `enabled` gate) | | `$data / $error / $status / $pending` | same names | Notable differences: - The query wraps a **real effect** (`query.__.effect`), visible in devtools. - Cancellation is real for `createRequestFx` effects (AbortSignal), not just discard. - Sourced config is available inline (a `Store` for `concurrency` / `retry.times` / `cache.staleAfter` / `enabled` / `timeout`), and `createJsonQuery`/`createJsonMutation` source `url` / `query` / `body` / `headers`. - `useUnit(query)` works directly in React and Vue via `@@unitShape`. What's not here yet (vs farfetched): the full sourced surface on _every_ field (we source the declarative-HTTP fields + a curated config set), and the superstruct / typed-contracts validation adapters specifically. See the [roadmap](https://github.com/Olovyannikov/effector-refetch/blob/main/ROADMAP.md). ## Within 0.x Pre-1.0 the API may still change between minor versions; breaking changes are called out in the changelog. Notable so far: - Web-storage cache adapters take an **options object** now: `localStorageCache({ version, maxAge })` (previously a prefix string). ================================================================================ # vs. farfetched URL: https://olovyannikov.github.io/effector-refetch/guide/vs-farfetched.html ================================================================================ # vs. farfetched [farfetched](https://ff.effector.dev) is the most complete data-fetching tool for effector and the obvious reference point. It's mature, well-designed, and **open-source / not archived**. This page is an honest comparison — including where farfetched is still ahead — so you can pick the right tool, not a sales pitch. The one-line difference: farfetched models a query as its **own event-based abstraction** (a `RemoteOperation` built from a `handler`); effector-refetch wraps **your real `Effect`** and exposes friendly inline config. Different philosophy, lots of overlap. ## Where farfetched is still ahead Be aware of these before switching: - **Maturity & ecosystem.** Years in production, a larger community, more accumulated recipes and edge-case fixes. effector-refetch is young (0.x) by comparison. - **Sourced parameters everywhere.** In farfetched almost _every_ field of _every_ operator can be a `Store`/source. effector-refetch sources the declarative-HTTP fields (`url` / `query` / `body` / `headers` in `createJsonQuery` / `createJsonMutation`) plus a curated config set — `enabled`, `concurrency`, `retry.times`, `cache.staleAfter`, `refetchInterval`, `timeout` — and expects the rest to come from the effect's params (often via `sample`). Closer than it was, but still narrower. - **A couple of named validation adapters.** farfetched ships dedicated `@farfetched/{runtypes,io-ts,superstruct,typed-contracts,zod}`. effector-refetch now matches `runtypesContract`, `ioTsContract`, `zodContract`, plus `standardSchemaContract` (covers any Standard-Schema lib — valibot, arktype, zod 4, …), `@withease/contracts` (works natively — same `Contract` shape, no adapter), and `createContract`. The remaining named gaps are **superstruct** and **typed-contracts** (both reachable via Standard Schema where supported). ## Where effector-refetch is different (and often nicer) - **Effect-first.** The unit of work is your real `Effect` (incl. `attach` factories) — visible in devtools, composable, testable on its own. `query.__.effect` is exactly what you passed. - **Friendly config.** `retry` / `cache` / `concurrency` / `timeout` are inline options on `createQuery` **and** standalone operators (`retry()`, `cache()`, `concurrency()`, `timeout()`, `keepFresh()`, `applyBarrier()`) — sugar over the same machinery. - **Real cancellation.** `createRequestFx` gives an `AbortSignal`; `TAKE_LATEST`/`cancel` actually abort the in-flight request, not just discard its result. - **Declarative HTTP for reads _and_ writes.** `createJsonQuery` + `createJsonMutation`, both over a reusable request effect (`createJsonRequestFx`) you can drop into any `createQuery`. - **`@@trigger` both ways.** Every query/mutation _is_ a `@@trigger` (`fired` = `finished.done`), so it drives farfetched's `keepFresh({ triggers })` — and our `keepFresh` accepts any `@@trigger` (withease web-API triggers, farfetched-compatible triggers) or a plain `Event` in return. - **Built-in pagination.** `createInfiniteQuery` (bidirectional `fetchNext`/`fetchPrevious`, windowing) — farfetched has no built-in equivalent. - **Built-in offline mode.** Both libraries have a `createBarrier` mutex (e.g. 401 → refresh → replay); effector-refetch adds a ready-made `createNetworkBarrier` that pauses queries while the browser is offline. - **Router, structurally.** `attachToRoute({ route, query })` starts/resets a query on route open/close — without importing atomic-router (any `{ opened, closed }` shape works). - **Tooling.** Visual devtools panels for **React, Vue and Solid**, an introspection event stream, an `llms.txt` + a Claude Code agent skill. - **Bindings & Suspense.** `useUnit(query)` plus `useQuery` helpers for React / Vue / Solid, and `useSuspenseQuery` for React Suspense. - **Small, dependency-free core** (~7 kB) under active development toward 1.0. ## Side by side | | farfetched | effector-refetch | | -------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | unit of work | internal event-based executor | your real `Effect` — first-class | | API style | operators | inline options **and** operators | | operators | `retry`/`cache`/`concurrency`/`timeout`/`keepFresh`/`barrier` | same set — inline **and** standalone | | sourced config | sourced **everything** | HTTP fields (`url`/`query`/`body`/`headers`) + curated config | | validation | runtypes / io-ts / superstruct / typed-contracts / zod | runtypes / io-ts / zod / Standard Schema / `@withease/contracts` (native) / `createContract` | | declarative HTTP | `createJsonQuery` + `createJsonMutation` | `createJsonQuery` + `createJsonMutation` (over `createJsonRequestFx`) | | pagination | — | `createInfiniteQuery` (bidirectional) | | cancellation | abort + discard | real `AbortSignal` via `createRequestFx` | | barrier / mutex | `createBarrier` + `applyBarrier` operator | `createBarrier` + `applyBarrier` operator | | offline mode | build it on a barrier | built-in `createNetworkBarrier` | | `@@trigger` protocol | implements + consumes (`keepFresh` triggers) | implements (every query/mutation) + consumes (`keepFresh` triggers) | | router | `@farfetched/atomic-router` | `attachToRoute` (structural — no router import) | | devtools | `@farfetched/dev-tools` | visual panels (React/Vue/Solid) + introspection stream | | bindings | `@farfetched/solid` + `useUnit` | react / vue / solid + `useQuery` + `useSuspenseQuery` | | SSR | `fork` / `allSettled` | `fork` / `allSettled` | | maturity / ecosystem | **larger, battle-tested** | young, actively developed | ## Which should you use? - **Use farfetched** if you want the most mature option today, lean heavily on sourced-everything config, or need the superstruct / typed-contracts validation adapters specifically. - **Use effector-refetch** if you prefer wrapping your own effects, want inline config, real cancellation, built-in pagination, declarative reads **and** writes, the barrier/offline primitives, structural router integration, cross-framework devtools, or a small core on an actively-maintained project. Already on farfetched and curious? The [migration guide](/guide/migration) + the `npx effector-refetch-codemod` tool handle most of the mechanical changes. ================================================================================ # Framework bindings URL: https://olovyannikov.github.io/effector-refetch/api/bindings.html ================================================================================ # Framework bindings A query implements effector's `@@unitShape` protocol, so you can pass it straight to `useUnit` from **effector-react**, **effector-vue** or **effector-solid** — no wrapper needed. ```tsx // React import { useUnit } from 'effector-react'; function UserCard({ id }: { id: number }) { const { data, pending, error, refetch } = useUnit(userQuery); return pending ? :
refetch(id)}>{data?.name}
; } ``` ```vue ``` `useUnit(query)` yields `{ data, error, status, pending, stale, enabled, params, start, refetch, refresh, reset, cancel }`. ## useQuery helpers Thin helpers that add derived booleans (`isInitial / isPending / isDone / isFail`): ```tsx // React — effector-refetch/react import { useQuery } from 'effector-refetch/react'; const { data, isPending, isFail, error, start } = useQuery(userQuery); useEffect(() => start(id), [id]); // queries never auto-start ``` ```vue ``` ```tsx // Solid — effector-refetch/solid (returns accessors — call them) import { useQuery } from 'effector-refetch/solid'; function UserCard(props: { id: number }) { const { data, isPending, isFail, start } = useQuery(userQuery); start(props.id); // queries never auto-start return
{isPending() ? 'Loading…' : data()?.name}
; } ``` React works with ``, Vue with the `EffectorScopePlugin`, Solid with effector-solid's `` — all for SSR / `fork`. Bindings require the matching optional peers (`react`+`effector-react` / `vue`+`effector-vue` / `solid-js`+`effector-solid`). ## Refetch on mount `useQuery` (all three frameworks) takes an options object — `refetchOnMount` refetches the query **with its last params** when the component subscribes: ```ts useQuery(userQuery, { refetchOnMount: true }); // refetch only if data is stale useQuery(userQuery, { refetchOnMount: 'always' }); // refetch every mount ``` It's a no-op until the query has run at least once (`status !== 'initial'`) and is enabled — it never starts a query that has no params yet. `true` needs a `cache.staleAfter` to have a notion of staleness; `'always'` ignores it. ## Suspense (React) `useSuspenseQuery` returns the data directly (never `null`): it **auto-starts** the query, suspends the nearest `` while loading, throws to the nearest Error Boundary on failure, and returns the data when done. ```tsx import { Suspense } from 'react'; import { useSuspenseQuery } from 'effector-refetch/react'; function UserName({ id }: { id: number }) { const user = useSuspenseQuery(userQuery, id); // suspends until ready return {user.name}; } function Page({ id }: { id: number }) { return ( Failed to load

}> }>
); } ``` This is for **client-side** Suspense: reads and triggers are scope-aware, but the settle signal is observed globally, so use it with `fork` outside of concurrent SSR streaming. ================================================================================ # Devtools URL: https://olovyannikov.github.io/effector-refetch/api/devtools.html ================================================================================ # Devtools A floating devtools panel — like TanStack Query's — that lists your queries with live status, params, data, error and a per-query event log. Available for **React** (`effector-refetch/devtools`), **Vue** (`effector-refetch/devtools/vue`) and **Solid** (`effector-refetch/devtools/solid`) with the same props, and tree-shaken out of your core bundle. ```tsx import { EffectorQueryDevtools } from 'effector-refetch/devtools'; function App() { return ( <> {import.meta.env.DEV && } ); } ``` Pass the queries you want to inspect, keyed by display name. The panel is scope-aware via effector-react's `` (so it works with SSR / `fork`). ## Vue The same panel for Vue — identical props, scope-aware via effector-vue's `EffectorScopePlugin`: ```vue ``` ## Solid Same panel for Solid — identical props, scope-aware via effector-solid's ``: ```tsx import { EffectorQueryDevtools } from 'effector-refetch/devtools/solid'; function App() { return ( <> {import.meta.env.DEV && } ); } ``` ## What it looks like ``` ┌ effector-refetch · devtools ───────────────────── ✕ ┐ │ ● user │ ● user done │ │ ● todos ••• │ PARAMS 7 │ │ │ DATA { "id": 7, "name": "…" } │ │ │ LOG start │ │ │ run #0 │ │ │ done (42ms) │ └───────────────┴────────────────────────────────────┘ ``` ## Try it live A real query wired to the library — click the buttons and watch status, data and the event log update (with retry on failure): ### Multiple queries The same panel, embedded inline (like TanStack's `DevtoolsPanel`), inspecting **several real queries at once**. Click **⚡ queries** to open it, pick a query in the left tab list, then drive it — each tab keeps its own status, params, data, error and log: The widget also wires two relationships you can watch fire: - **`connectQuery`** — loading `users` cascades into `profile` (its params derived from the result), so both tabs light up in sequence. - **`invalidate`** — _Invalidate all_ refetches every query that has already run, with its last params, bypassing cache freshness. ```ts import { connectQuery, invalidate } from 'effector-refetch'; // a successful users load starts profile with derived params connectQuery({ source: usersQuery, fn: ({ result }) => ({ params: { id: result[0].id } }), target: profileQuery, }); // one signal refetches everything that has run (e.g. after a mutation) invalidate({ on: dataChanged, refetch: [usersQuery, todosQuery, profileQuery] }); ``` Collapsed, the floating panel is a small `⚡ queries (N)` pill in the corner; click to expand. - A colored dot per query: grey `initial`, amber `pending`, green `done`, red `fail`. - The detail pane shows **params**, **data** and **error** as JSON, plus a live **log** (`start / run / done / fail / aborted / cache-hit / cache-miss / retry`) with per-run duration — powered by the same [introspection stream](/api/introspection). ## Props | prop | type | default | | --------------- | --------------------------------- | ---------------- | | `queries` | `Record` | — | | `initialIsOpen` | `boolean` | `false` | | `position` | `'bottom-right' \| 'bottom-left'` | `'bottom-right'` | ::: tip Render it only in development (`import.meta.env.DEV`) and it won't ship to production. Prefer headless logging instead? Use [`attachQueryLogger`](/api/introspection#attachquerylogger). ::: ================================================================================ # HTTP & validation URL: https://olovyannikov.github.io/effector-refetch/api/http.html ================================================================================ # HTTP & validation ## createRequestFx Wrap any HTTP client into a typed, abort-aware effector effect with normalized errors: ```ts import { ofetch } from 'ofetch'; import { createRequestFx, createQuery } from 'effector-refetch'; const getUserFx = createRequestFx<{ id: number }, User>(({ id }, { signal }) => ofetch(`/api/users/${id}`, { signal }), ); const userQuery = createQuery({ effect: getUserFx, cache: true }); ``` The handler receives an `AbortSignal`; the query owns the controller and fires it on `cancel` / `reset` and on `TAKE_LATEST` supersede — so the request actually aborts. Errors are normalized to `RequestError` (`status`, `data`). It returns an `AbortableEffect` (exported, if you need to annotate it); pass it to `createQuery` / `createMutation` / etc. just like a plain `Effect`. ### Error guards Narrow `$error` / `finished.fail` payloads with typed guards instead of `instanceof` + casts: ```ts import { isRequestError, isHttpError, isTimeoutError, isValidationError } from 'effector-refetch'; isHttpError(error); // a RequestError carrying a status isHttpError(error, 404); // exactly 404 isHttpError(error, (s) => s >= 500); // any 5xx isTimeoutError(error); // aborted past its `timeout` isValidationError(error); // a failed contract / validate (narrows to .validationErrors) isRequestError(error); // any normalized transport error ``` ```ts import { sample } from 'effector'; // refresh the token on a 401 sample({ clock: api.finished.fail, filter: ({ error }) => isHttpError(error, 401), target: authBarrier.lock, }); // a friendly message per error kind const $message = api.$error.map((e) => isHttpError(e, (s) => s >= 500) ? 'Server error' : isTimeoutError(e) ? 'Timed out' : e ? 'Failed' : null, ); ``` More in the [error-handling recipe](/recipes/error-handling#type-guards). It's just an effect, so anything works inside: multipart **FormData** uploads ([`examples/form-data.ts`](https://github.com/Olovyannikov/effector-refetch/blob/main/examples/form-data.ts)), **GraphQL** (POST `{ query, variables }` — see the [GraphQL recipe](/recipes/graphql)), or streaming updates ([SSE & WebSocket](/recipes/streaming)). ## createJsonQuery Declarative endpoint over the global `fetch` (no HTTP-client dependency): ```ts import { createJsonQuery, HTTP_METHODS, zodContract } from 'effector-refetch'; export const getProductsQuery = createJsonQuery({ request: { url: 'https://api/products', query: ({ search }) => ({ search, limit: 20 }) }, response: { contract: zodContract(ProductList) }, concurrency: 'TAKE_LATEST', cache: { staleAfter: 30_000 }, }); export const createUser = createJsonQuery({ request: { url: 'https://api/users', method: HTTP_METHODS.POST, body: (u) => u }, }); ``` `request`: `{ url, method?, query?, body?, headers? }`. Each field is a function of params (or, for `url`, a static string). Abort-aware, normalized `RequestError`, optional contract, plus all the usual query options. ### Sourced fields (reactive, fork-correct) Any request field can also be read from a `Store` — handy for an auth token or base URL that lives in state. It's wired through `attach`, so each `fork`/SSR scope uses its own value: ```ts const userQuery = createJsonQuery<{ id: number }>({ request: { // combine a store with params via { source, fn } url: { source: $apiBase, fn: (base, { id }) => `${base}/users/${id}` }, // or pass a Store directly headers: { source: $token, fn: (token) => ({ authorization: `Bearer ${token}` }) }, }, }); ``` A field is `(params) => T`, a `Store`, or `{ source: Store, fn: (value, params) => T }`. Stores are resolved per scope at request time — no global mutable client. ## createJsonMutation The write-side mirror of `createJsonQuery`: same `request` shape (sourced fields included), defaults to `POST`, returns a `Mutation` (no cache / refresh / stale). ```ts import { createJsonMutation, invalidate } from 'effector-refetch'; const createUser = createJsonMutation({ request: { url: 'https://api/users', body: (u) => u }, // method defaults to POST }); const deleteUser = createJsonMutation({ request: { url: (id) => `https://api/users/${id}`, method: HTTP_METHODS.DELETE }, }); invalidate({ on: createUser, refetch: usersQuery }); // refetch the list on success createUser.mutate({ name: 'Ada' }); ``` ## createJsonRequestFx The reusable building block behind both: a declarative request **effect** (same `request` shape, sourced fields, abort-aware, normalized `RequestError`) you can pass anywhere an effect is expected — `createQuery` / `createMutation` / `createInfiniteQuery` / `connectQuery` — instead of hand-writing `createRequestFx`. ```ts import { createJsonRequestFx, createInfiniteQuery } from 'effector-refetch'; const fetchPageFx = createJsonRequestFx<{ params: { tag: string }; pageParam: number }, Page>({ url: ({ params, pageParam }) => `/api/feed?tag=${params.tag}&cursor=${pageParam}`, }); const feed = createInfiniteQuery({ effect: fetchPageFx, initialPageParam: 0, getNextPageParam: ({ lastPage }) => lastPage.nextCursor, }); ``` ## Validation (contracts) Validate a response against a schema; a failure becomes a **retryable** `ValidationError`: ```ts import { createQuery, zodContract, standardSchemaContract, runtypesContract, ioTsContract, createContract, } from 'effector-refetch'; createQuery({ effect: getUserFx, contract: zodContract(UserSchema) }); // zod createQuery({ effect: getUserFx, contract: standardSchemaContract(UserSchema) }); // valibot / zod 3.24+ / arktype createQuery({ effect: getUserFx, contract: runtypesContract(User) }); // runtypes createQuery({ effect: getUserFx, contract: ioTsContract(User) }); // io-ts codec createQuery({ effect: getUserFx, contract: createContract({ isData: isUser }) }); // manual / any lib createQuery({ effect: getPriceFx, validate: ({ result }) => result >= 0 || ['negative price'] }); ``` Contracts are **structural** — the schema libraries are not imported; you pass your own schema/validator. On failure, `$error` is a `ValidationError` with `.validationErrors`. `contract` and `validate` are **two fields that compose**: if both are given, the `contract` runs first, then `validate`, and the first failure wins. This is the intended, final design — they stay separate (a schema check vs. an ad-hoc predicate), not a single merged field. ::: tip @withease/contracts — no adapter needed [`@withease/contracts`](https://withease.effector.dev/contracts/) produces objects with the exact `{ isData, getErrorMessages }` shape the `contract` option expects, so its combinators are passed **directly** — no wrapper: ```ts import { obj, str, num, arr } from '@withease/contracts'; createQuery({ effect: getUserFx, contract: obj({ id: num, name: str, tags: arr(str) }) }); ``` ::: ::: tip Any other library Anything is one `createContract` away — superstruct, typed-contracts, a hand-written guard: ```ts import { is } from 'superstruct'; createContract({ isData: (raw) => is(raw, UserStruct) }); ``` `standardSchemaContract` already covers every [Standard Schema](https://standardschema.dev) library (valibot, arktype, zod ≥3.24). ::: ================================================================================ # Introspection URL: https://olovyannikov.github.io/effector-refetch/api/introspection.html ================================================================================ # Introspection ## Devtools labelling Pass `name` to label the units in the effector inspector — **the public surface and every internal seam**, so the whole pipeline is readable, not just the entry points: ```ts const todos = createQuery({ effect: fetchTodosFx, name: 'todos' }); // public: todos.start, todos.$data, todos.$status, todos.runFx, todos.inspect.* // internal: todos.requested, todos.proceed, todos.toExec, todos.lookupFx, todos.toRun, // todos.rawDone, todos.acceptedDone, todos.scheduleRetry, todos.failed, // todos.finalFail, todos.$runId, todos.$attempts, … ``` No `name`? Pass `debug: true` to label everything under a generic `query.*` namespace. Without either, internal units stay anonymous (zero inspector noise in production). ## Lifecycle event stream Every query exposes `query.__.inspect` — effector events you can subscribe to: | event | payload | | ------------------------ | ---------------------------- | | `start` | `{ params }` | | `run` | `{ params, attempt }` | | `done` | `{ params, result }` | | `fail` | `{ params, error }` | | `aborted` | `{ params }` | | `cacheHit` / `cacheMiss` | `{ params }` | | `retry` | `{ params, attempt, error }` | ## attachQueryLogger Turn the stream into structured, timed log entries: ```ts import { attachQueryLogger } from 'effector-refetch'; const stop = attachQueryLogger(todos, { name: 'todos' }); // → { query: 'todos', type: 'run', params, attempt: 0 } // { query: 'todos', type: 'done', params, durationMs: 42 } stop(); // unsubscribe ``` Pass a custom `handler` to forward entries into your own logger or the effector inspector. Entry types: `start | run | done | fail | aborted | cache-hit | cache-miss | retry`. ================================================================================ # Mutations & invalidation URL: https://olovyannikov.github.io/effector-refetch/api/mutations.html ================================================================================ # Mutations & invalidation ## createMutation A mutation is a write-flavored query: the same effect-first engine (status, retry, concurrency, lifecycle) without cache/refresh/stale, plus a `mutate` alias. Concurrency defaults to `TAKE_EVERY` so independent writes don't cancel each other. ```ts import { createMutation } from 'effector-refetch'; const addTodo = createMutation({ effect: addTodoFx, retry: 2 }); addTodo.mutate({ text: 'Buy milk' }); ``` Exposes `{ start, mutate, reset, cancel, $data, $error, $status, $pending, $params, finished, aborted }` and works with `useUnit(mutation)`. ## invalidate Refetch queries when something succeeds: ```ts import { invalidate } from 'effector-refetch'; invalidate({ on: addTodo, refetch: todosQuery }); ``` - **`on`** — a Mutation/Query (fires on success), an `Event`, or an `Effect`; or an array. - **`refetch`** — a query or array; each re-runs with its last params, only if it ran before (`status !== 'initial'`), bypassing cache freshness. - **`filter`** — optional gate on the trigger payload (e.g. `{ params, result }`). ## update Patch a query's `$data` directly from a result — no refetch: ```ts import { update } from 'effector-refetch'; update({ query: todosQuery, on: addTodo, fn: ({ data, result }) => [...(data ?? []), result] }); ``` ## optimisticUpdate Apply immediately on `start`, roll back on failure, optionally reconcile on success: ```ts import { optimisticUpdate } from 'effector-refetch'; optimisticUpdate({ query: todosQuery, on: addTodo, update: ({ data, params }) => [{ id: -1, ...params }, ...(data ?? [])], commit: ({ data, result }) => (data ?? []).map((t) => (t.id === -1 ? result : t)), }); ``` Combine optimistic feedback with `invalidate` to reconcile against server truth. ================================================================================ # Operators URL: https://olovyannikov.github.io/effector-refetch/api/operators.html ================================================================================ # Operators Every inline `createQuery` option is sugar over a **standalone operator** — `import` them and apply to any query/mutation (even one built elsewhere). They're composable and tree-shakeable. ```ts import { concurrency, retry, cache, timeout, keepFresh, applyBarrier } from 'effector-refetch'; ``` ## `concurrency` How overlapping runs behave: `TAKE_LATEST` (default), `TAKE_FIRST`, `TAKE_EVERY`. ```ts concurrency(searchQuery, { strategy: 'TAKE_LATEST' }); // new run aborts the previous ``` ## `retry` `retry(query, 3)` or a config. Each attempt is a real effect call; `filter` decides which failures retry, `suppressIntermediateErrors` keeps `$error` clean until the final attempt. ```ts import { exponentialDelay } from 'effector-refetch'; retry(userQuery, { times: 3, delay: exponentialDelay(200), filter: ({ error }) => (error as RequestError).status !== 404, // don't retry 404 }); ``` ## `cache` `cache(query)` (in-memory) or a config (adapter / `staleAfter` / `key` / `swr` / `dedupe` / `purge`). ```ts cache(productsQuery, { staleAfter: 30_000, swr: true, purge: loggedOut }); ``` ## `timeout` Per-attempt deadline (ms): aborts the in-flight request and **fails** the run (retryable) if it exceeds it. `0` disables it. Distinct from `refetchInterval` (poll cadence). ```ts timeout(reportQuery, 5000); // give up a single attempt after 5s ``` ## `keepFresh` Refetch the query with its **last params** whenever a `source` store changes **or** a `@@trigger` fires — dependency-based freshness (filters, locale, viewer, a write succeeding, a websocket ping). No-op until it has run and while disabled. ```ts keepFresh(productsQuery, { source: $filters }); // or source: [$filters, $locale] // triggers: anything implementing the @@trigger protocol, or a plain effector Event keepFresh(productsQuery, { triggers: [createProductMutation, tabFocused] }); ``` `triggers` accepts our own queries/mutations (they implement `@@trigger` — `fired` = `finished.done`), [withease](https://withease.effector.dev/) web-API triggers, farfetched-compatible triggers, or a raw `Event`. Each trigger's `setup` is fired once when wired and stays active for the app's lifetime. ## `@@trigger` protocol Every query and mutation **is** a [`@@trigger`](https://withease.effector.dev/protocols/trigger.html): `query['@@trigger']()` returns `{ fired, setup, teardown }` where `fired` is `finished.done`. So a query can drive **farfetched's** `keepFresh({ triggers })` (and vice-versa), or any protocol consumer: ```ts import { keepFresh } from '@farfetched/core'; keepFresh(someFarfetchedQuery, { triggers: [ourQuery] }); // ourQuery succeeds → farfetched refetches ``` `isTrigger(x)` narrows to the protocol. Our units are always-on triggers: `setup`/`teardown` exist for protocol compatibility but don't gate firing (the query runs on its own scoped lifecycle). ## `applyBarrier` Gate an already-created query/mutation on a [barrier](/recipes/auth-barrier) (e.g. 401 → token refresh → resume). Pass `null` to detach. ```ts const auth = createBarrier({ perform: refreshTokenFx }); applyBarrier(userQuery, auth); ``` ## Applying an operator more than once Two well-defined behaviors, by operator kind: - **Last-wins** — `concurrency` / `retry` / `cache` / `timeout` / `applyBarrier` are engine _setters_: a second call **replaces** the first. `retry(q, 1); retry(q, 3)` ⇒ 3 retries; `applyBarrier(q, null)` detaches. - **Additive** — `keepFresh` / `invalidate` / `update` _add wiring_ each call: registering two `keepFresh` sources means **either** change refetches. This is intentional and tested (`test/multi-operators.test.ts`) — last-wins for the single-valued config knobs, additive for the ones that register reactions. --- All of these equal the corresponding `createQuery({ … })` option — use whichever reads better. Runnable: [`examples/operators.ts`](https://github.com/Olovyannikov/effector-refetch/blob/main/examples/operators.ts). ================================================================================ # Pagination URL: https://olovyannikov.github.io/effector-refetch/api/pagination.html ================================================================================ # Pagination ## createInfiniteQuery Cursor/offset pagination that accumulates pages. `start` loads the first page (resetting), `fetchNext` appends the next — driven by `getNextPageParam`. ```ts import { createInfiniteQuery } from 'effector-refetch'; const feed = createInfiniteQuery({ effect: fetchPageFx, // Effect<{ params, pageParam }, Page> initialPageParam: 0, getNextPageParam: ({ lastPage }) => lastPage.nextCursor ?? null, // null/undefined = done }); feed.start({ tag: 'cats' }); feed.fetchNext(); // appends; no-op when $hasNextPage is false or already loading ``` Exposes `$pages` (= `$data`), `$pageParams`, `$hasNextPage`, `$status`, `$pending`, `$error`, `finished.{done,fail}`, and `useUnit(feed)` support. `getNextPageParam` receives `{ lastPage, allPages, lastPageParam, allPageParams }` and returns the next page param, or `null`/`undefined` when there are no more pages. ### Bidirectional + windowing Add `getPreviousPageParam` to enable `fetchPrevious` (prepends), and `maxPages` to cap the window (drops from the opposite end): ```ts const feed = createInfiniteQuery({ effect: fetchPageFx, initialPageParam: 10, // start in the middle getNextPageParam: ({ lastPage }) => lastPage.next ?? null, getPreviousPageParam: ({ firstPage }) => firstPage.prev ?? null, maxPages: 3, }); feed.fetchPrevious(); // prepend; gated by $hasPreviousPage ``` Exposes `$hasPreviousPage` alongside `$hasNextPage`. ## `combineQueries` — parallel queries Aggregate several independent queries into combined stores (the effector-flavored `useQueries`): ```ts import { combineQueries } from 'effector-refetch'; const { $data, $pending, $isSuccess, $isError, $statuses, $errors } = combineQueries([userQuery, postsQuery]); // $data: [User | null, Post[] | null] $pending: any in flight $isSuccess: all done ``` Start the queries as usual; `combineQueries` just reads their combined state. ::: tip The page effect is a plain `Effect<{ params, pageParam }, Page>` — use a regular `createEffect`/`handler`, not an abort-aware `createRequestFx` effect (which has a different `{ params, signal }` calling convention). ::: Built on `createQuery`, so the page fetch inherits concurrency and cancellation. ================================================================================ # Queries URL: https://olovyannikov.github.io/effector-refetch/api/queries.html ================================================================================ # Queries ```ts import { createQuery } from 'effector-refetch'; const query = createQuery({ effect, // Effect (or `handler`) initialData, enabled, // Store mapData, mapError, contract, validate, // see HTTP & validation retry, // number | { times, delay?, filter?, suppressIntermediateErrors? } cache, // true | { adapter?, staleAfter?, key?, purge?, swr?, dedupe? } concurrency, // 'TAKE_LATEST' (default) | 'TAKE_FIRST' | 'TAKE_EVERY' name, // devtools label }); ``` ## Options - **`effect`** — your `Effect`. `handler: async params => …` is sugar. - **`concurrency`** — how overlapping runs behave: - `TAKE_LATEST` (default) — new run supersedes & aborts the previous. - `TAKE_FIRST` — ignore new runs while one is in flight. - `TAKE_EVERY` — every run applies (last result wins `$data`). - **`retry`** — `number` or `{ times, delay?, filter?, suppressIntermediateErrors? }`. Each retry is a real effect call. Helpers: `linearDelay`, `exponentialDelay`. - **`cache`** — `true` or a config (see [caching](#caching)). - **`enabled`** — `Store` gate; while `false`, `start`/`refresh` are skipped. - **`refetchInterval`** — poll every N ms (`number` or `Store`, 0 = off). See [Auto-refetch & polling](/recipes/auto-refetch). - **`timeout`** — per-attempt deadline in ms (`number` or `Store`, 0 = off): if a run exceeds it, the in-flight request is aborted and the run **fails** (retryable, so it composes with `retry`). Distinct from `refetchInterval` (how _often_ to poll) — `timeout` is how _long_ one attempt may take. - **`structuralSharing`** — preserve referential identity of unchanged parts of the result (fewer re-renders). - **`placeholderData`** — a value or `(prev) => …` shown while there's no real data; `$isPlaceholderData` is `true` until the first real result. Unlike `initialData`, it's not treated as cached. - **`mapData` / `mapError`** — normalize result / error before the stores. `query.prefetch(params)` warms the cache for `params` **without** touching `$data`/`$status` (no-op without a cache, skips when already fresh) — e.g. prefetch the next page on hover. ::: tip keepPreviousData by default `$data` isn't cleared on a new `start` — it keeps the previous result until the new one arrives. So when params change, the old data stays visible while the new fetch runs (TanStack's `keepPreviousData`), out of the box. Use `reset()` to clear explicitly. ::: Share these across many queries with a [factory](/recipes/defaults). ## Lifecycle events ```ts query.finished.done; // { params, result } — a run succeeded query.finished.fail; // { params, error } — a run failed query.finished.finally; // { params, status: 'done' | 'fail' } query.aborted; // { params } — cancel / reset / TAKE_LATEST supersede / skip ``` For **farfetched compatibility**, `finished` also exposes: ```ts query.finished.success; // alias of finished.done (same event) query.finished.failure; // alias of finished.fail (same event) query.finished.skip; // { params } — the `enabled` gate blocked a run ``` `finished.skip` fires only on the `enabled`-gate skip (the query didn't execute). The broader `aborted` event still fires for **every** discarded run — skip, `cancel`, `reset`, and a `TAKE_LATEST` supersede — so it stays a superset of `skip`. (Unlike farfetched, `finished.finally` fires on `done`/`fail` only, not on skip — observe skips via `finished.skip` / `aborted`.) ## Operators `concurrency` / `retry` / `cache` are also standalone, composable operators — the inline options are sugar over them. Apply them directly, even after creation: ```ts import { createQuery, concurrency, retry, cache, timeout } from 'effector-refetch'; const search = createQuery({ effect: searchFx }); concurrency(search, { strategy: 'TAKE_LATEST' }); retry(search, { times: 3, delay: exponentialDelay(200) }); cache(search, { staleAfter: 30_000, purge: loggedOut }); timeout(search, 5000); // abort + fail a run that takes over 5s ``` ## Caching `cache: { adapter?, staleAfter?, key?, purge?, swr?, dedupe? }` - **`swr: true`** — serve a stale entry immediately, revalidate in the background (`$stale` flips `true` → `false`). - **`dedupe: true`** — coalesce identical in-flight requests (by key) into one effect run. - Adapters: `inMemoryCache({ maxAge?, maxEntries?, onHit?, onMiss?, onExpired?, onEvicted? })` (LRU GC + events), `localStorageCache({ version?, maxAge? })` / `sessionStorageCache(...)` (bump `version` to invalidate old data), `voidCache`. ## Sourced (reactive) config Inline `concurrency`, `retry.times`, `cache.staleAfter` (and `enabled`) accept a `Store` instead of a constant — read reactively and **fork-correctly** (each scope sees its own value): ```ts const $retries = createStore(0); createQuery({ effect: fx, retry: { times: $retries, delay: exponentialDelay(200) } }); ``` ## connectQuery ```ts connectQuery({ source, fn, target, filter? }); // single source connectQuery({ source: { a, b }, fn, target, filter? }); // multiple (waits for all done) ``` `fn` receives `{ result, params }` per source and returns `{ params }` for the target. ================================================================================ # API reference URL: https://olovyannikov.github.io/effector-refetch/api/reference.html ================================================================================ # API reference > Auto-generated from the public type entry points — always in sync with the build. > For prose and examples, see the [API](/api/queries) and [Recipes](/recipes/ssr-and-testing) sections. ## `effector-refetch` | Export | Kind | Signature | | --- | --- | --- | | [`applyBarrier`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/operators.ts#L150) | function | `applyBarrier(query, barrier): Q` | | [`attachQueryLogger`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/inspect.ts#L39) | function | `attachQueryLogger(query, options): () => void` | | [`attachToRoute`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/router.ts#L30) | function | `attachToRoute(config): void` | | [`cache`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/operators.ts#L52) | function | `cache(query, opts): Q` | | [`combineQueries`](https://github.com/Olovyannikov/effector-refetch/blob/main/src/combine-queries.ts#L26) | function | `combineQueries(queries): CombinedQueries` | | [`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)