Auth & barrier (pause the environment)
Sometimes you need to pause every request, do something, then resume — the classic case being a 401: pause, refresh the token, replay the queued requests.
createBarrier is a mutex that queries wait on. While it's locked, any gated query that tries to run blocks; when it unlocks, the queued requests proceed.
import { sample } from 'effector';
import { createBarrier, createQueryFactory } from 'effector-refetch';
// the barrier runs the refresh when it locks, and unlocks when refresh settles
const authBarrier = createBarrier({ perform: refreshTokenFx });
// every query/mutation built here waits on the barrier
const { createQuery, createMutation } = createQueryFactory({ barrier: authBarrier });
const profile = createQuery({
effect: getProfileFx, // throws { status: 401 } when the token is stale
retry: { times: 1, filter: ({ error }) => 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:
getProfileFxfails with401→ the barrier locks andrefreshTokenFxruns.- The
retryschedules a re-run — but it waits at the barrier. - Other queries started meanwhile also queue.
refreshTokenFxsettles → the barrier unlocks → the retry (and the queue) run with the fresh token.
Try it live
Expire the token, then press Fetch ×3: one 401 locks the barrier, the refresh runs once, and every retried request resumes and succeeds when it unlocks:
API
const barrier = createBarrier({ perform?: Effect<void, any> });
barrier.lock(); // close — gated queries wait
barrier.unlock(); // open — queued queries proceed
barrier.$locked; // Store<boolean>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):
const q = createQuery({ effect: fx, barrier: authBarrier });
// or, after creation:
applyBarrier(existingQuery, authBarrier);Lock it from the failure, not from finished.fail
finished.fail only fires for the final failure — after the retries are exhausted. A lock driven by it arrives too late: the retry has already gone out with the stale token. Drive the lock from the raw effect instead (fx.failData), which fires on the very first 401:
// ✅ fires on the first 401, before the retry is scheduled
sample({ clock: getProfileFx.failData, filter: (e) => e.status === 401, target: authBarrier.lock });
// ❌ only after every retry has already failed
sample({
clock: profile.finished.fail,
filter: ({ error }) => error.status === 401,
target: authBarrier.lock,
});An HTTP layer works just as well — lock where you see the 401, before rethrowing. Because that code runs outside effector's call stack, bind it to the scope:
import { scopeBind } from 'effector';
async function request(url: string) {
const response = await fetch(url);
if (response.status === 401) {
scopeBind(authBarrier.lock, { safe: true })();
throw Object.assign(new Error('Unauthorized'), { status: 401 });
}
return response.json();
}Infinite queries
createInfiniteQuery takes barrier and retry, so paginated feeds get the same flow: start, fetchNext and fetchPrevious wait while the barrier is locked, and a retried page fetch waits too — that is what replays the 401 page after the refresh.
const feed = createInfiniteQuery({
effect: fetchPageFx,
initialPageParam: 0,
getNextPageParam: ({ lastPage }) => lastPage.next,
barrier: authBarrier,
retry: { times: 1, filter: ({ error }) => error.status === 401 },
});refetchAll reloads the window straight through the effect, outside the page query, so its attempts are run by the reload loop itself — with the same retry config, and waiting on the barrier before every attempt. A 401 in the middle of the window therefore behaves like anywhere else: the refresh runs, the page is replayed, the reload finishes. Only when a page is out of attempts (or retry.filter rejects the error) does the reload fail, leaving the previous window on screen and the error in $error / $status.
Load a page or two, expire the token, then load the next one — the 401 locks the barrier, the refresh runs once, and the page retry resumes when it opens:
A runnable version of the same flow (fake API, no network) lives in examples/infinite-auth-barrier.ts: npx tsx examples/infinite-auth-barrier.ts.
Scope and SSR
The barrier is fork-safe: both the lock flag and the queue of waiting runs live in stores, so concurrent scopes — SSR requests, tests — block and release independently. Locking one scope leaves the others running.
Locking from outside effector
A lock() called from an HTTP layer, an SDK callback or anything else outside effector's call stack lands on the scope-less app, and scoped queries never see it. Wrap it in scopeBind(barrier.lock, { safe: true }) (as above), or drive the lock declaratively with sample.