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.
import {
concurrency,
retry,
cache,
timeout,
debounce,
fallback,
keepFresh,
applyBarrier,
} from 'effector-refetch';concurrency
How overlapping runs behave: TAKE_LATEST (default), TAKE_FIRST, TAKE_EVERY, QUEUE.
concurrency(searchQuery, { strategy: 'TAKE_LATEST' }); // new run aborts the previous
concurrency(saveMutation, { strategy: 'QUEUE' }); // writes run strictly one after anotherQUEUE serializes runs: the next starts only after the previous settles, failures don't break the chain, and cancel/reset flush the waiting runs (they abort as 'cancelled'). Combined with a lane key the serialization is per lane.
Add a lane key to make runs compete only with runs of the same key — refreshing one table row no longer cancels its neighbours:
concurrency(rowQuery, { strategy: 'TAKE_LATEST', key: ({ rowId }) => String(rowId) });Supersede (TAKE_LATEST) and busy-drop (TAKE_FIRST) apply within a lane; cancel / reset still affect every lane. $data stays single per query — lanes partition cancellation, not data (the last lane to settle wins the store; keep per-key data in a cached query keyed by params instead).
Try it live — three pokedex slots over the real PokeAPI, each slot is a lane:
Runnable script version: examples/concurrency-lanes.ts.
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.
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 / fillOnAbort).
cache(productsQuery, { staleAfter: 30_000, swr: true, purge: loggedOut });
cache(feedQuery, { swr: { silent: true }, fillOnAbort: true });swr: { silent: true }— a failed background revalidation keeps serving the stale entry silently:$error/$statusstay untouched,finished.failstill fires for observers (andstartAsyncstill rejects).fillOnAbort: true— a SUPERSEDED in-flight run is allowed to finish so its response still lands in the cache; only its connection to$data/status is severed. Explicitcancel/resetaborts for real.
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).
timeout(reportQuery, 5000); // give up a single attempt after 5sdebounce
Wait N ms before a run executes; a newer run in the same lane started during the wait supersedes it before it hits the network — a true debounce for search-as-you-type under TAKE_LATEST. 0 disables.
debounce(searchQuery, 300); // or createQuery({ debounce: 300 })fallback
Recover a final failure (after retries) into data: $data gets the value, $status becomes done, finished.done fires. The value is not written to the cache (it isn't server truth), and aborts/skips are exempt. Pass null to detach.
fallback(productsQuery, []); // empty list instead of an error screen
fallback(profileQuery, ({ error, params }) => cachedProfileOr(params, error));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.
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 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: 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:
import { keepFresh } from '@farfetched/core';
keepFresh(someFarfetchedQuery, { triggers: [ourQuery] }); // ourQuery succeeds → farfetched refetchesisTrigger(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 (e.g. 401 → token refresh → resume). Pass null to detach.
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/debounce/fallback/applyBarrierare engine setters: a second call replaces the first.retry(q, 1); retry(q, 3)⇒ 3 retries;applyBarrier(q, null)detaches. - Additive —
keepFresh/invalidate/updateadd wiring each call: registering twokeepFreshsources 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.