Resources vs TanStack Query, RTK Query, and SWR¶
This page is the honest, feature-by-feature comparison between re-frame2 managed resources and the JavaScript server-state libraries you may already know — TanStack Query (the React Query family), RTK Query, and SWR. The conceptual model is in Server state: resources; this page is the scorecard. It tells you what is matched, what is deliberately different, what has landed, and — bluntly — what is deliberately out of scope for this phase.
The one sentence to carry through every row:
Same problem, different physics. A query library is a cache wired to component lifecycle; a re-frame2 resource is a cache wired to causes — routes, events, machines — with the viewer identity promoted from convention to a required structural key.
If you want the design rationale rather than the comparison, read Inside out: why views come last first; the choices below all fall out of that essay.
Resources are an optional artefact, and pre-alpha
Server state in re-frame2 is the optional day8/re-frame2-resources artefact, and it is pre-alpha: the read path, the mutation path, optimistic mutation rollback, active-owner polling, and infinite / load-more feeds have all landed. The remaining query-library features are deliberately out of scope for this HTTP-only phase — normalized/GraphQL caches and offline/cross-tab persistence — and each such row below names where that line sits. Nothing here is back-compat-frozen.
How to read the status column¶
Each row carries a status. They mean precisely:
| Status | Meaning |
|---|---|
| Landed | Shipped in the reference implementation (re-frame.resources) and pinned by tests. |
| Different by design | A capability the query libraries have, expressed differently here on purpose — usually because re-frame2 already has a more general mechanism (the subscription graph, the event loop) that subsumes it. |
| Out of scope | Deliberately not a resources concern — a different artefact, a different phase, or a non-goal. |
| Deferred (later slice) | A real parity gap, deliberately held for a later slice, with no shipped contract yet. Used here only for offline persistence / cross-tab broadcast. The earlier parity-tranche proposals — optimistic rollback (EP-0019), active-owner polling (EP-0020), and infinite resources (EP-0021) — have all since landed and moved to Landed. |
The parity matrix¶
| Dimension | TanStack Query | RTK Query | SWR | re-frame2 resources | Status |
|---|---|---|---|---|---|
| Keyed cache | queryKey array |
endpoint + serialized arg | string/array key | [scope resource-id canonical-params] triple; params are schema-validated and canonicalized |
Landed |
| Cache home | QueryClient (module-level, app-global) |
Redux store slice | module-level SWRConfig cache |
framework-owned runtime partition of each frame (:rf.runtime/resources); never your app-db, never process-global |
Different by design |
| Staleness (SWR semantics) | staleTime; stale-while-revalidate |
keepUnusedDataFor + refetch triggers |
always SWR; dedupingInterval |
:stale-after-ms; :loaded entries serve immediately, refetch on next ensure when stale |
Landed |
| Request deduplication | in-flight queries coalesce | automatic | dedupingInterval window |
ensure of an in-flight key joins the existing request (one fetch, two owners) |
Landed |
| Fresh-skip (cache hit, no fetch) | fresh query returns cached, no fetch | served from store | within deduping window | fresh :loaded ensure serves the cached value, attaches the owner, fetches nothing |
Landed |
| Garbage collection | gcTime (was cacheTime); GC when no observer |
keepUnusedDataFor after last subscriber |
revalidation-driven; weak retention | :gc-after-ms after the last owner lease is released; timer re-checks owners before collecting |
Landed |
| Scope / cache identity boundary | viewer id is one queryKey segment, by convention |
baked into arg by convention | part of the key, by convention | scope is a required, structural key segment; forgetting it is a loud registration/subscription error, never a silent cross-viewer leak | Different by design |
| Invalidation | queryClient.invalidateQueries({queryKey}), imperative |
tag-based (providesTags / invalidatesTags) |
mutate(key), imperative |
tag-based, declared as a consequence of a named mutation; scoped by default; per-target scoped descriptors; cross-scope is an audited opt-in | Landed |
| Mutations | useMutation |
builder.mutation |
bound mutate / useSWRMutation |
reg-mutation + :rf.mutation/execute; instance-keyed pending/result/error state; same managed-HTTP transport as reads |
Landed |
| Mutation consequences (patch/populate/seed) | setQueryData in onSuccess |
onQueryStarted + updateQueryData |
mutate with optimisticData/populateCache |
declarative :patches / :populates / :removes from the reply, then tag invalidation, with explicit timing |
Landed |
| Mutation completion continuation | onSuccess / onError callbacks |
lifecycle callbacks | promise resolution | call-site :reply-to event (not a callback); fires only for the accepted terminal reply |
Landed |
Projection / select |
select option |
selectFromResult |
derived in component | no :select key — projections are ordinary subscriptions layered over [:rf.resource/data …] |
Different by design |
| Optimistic updates + rollback | onMutate snapshot + onError rollback |
updateQueryData + undo patch |
optimisticData + rollbackOnError |
:optimistic (exact-target) / :optimistic-tags (tag-addressed) plan applied pre-request; runtime records the inverse; deterministic commit / rollback / reconcile on settle, with :on-conflict governing a contested rollback |
Landed |
| Polling / refetch interval | refetchInterval |
pollingInterval |
refreshInterval |
:poll-interval-ms — revalidates every N ms while the entry is actively owned and the tab is visible; the third freshness timer beside stale/GC |
Landed |
| Refetch on window focus / reconnect | refetchOnWindowFocus / refetchOnReconnect |
refetchOnFocus / refetchOnReconnect |
revalidateOnFocus / revalidateOnReconnect |
install-revalidation-listeners! per frame; refetches only entries that are stale AND owned |
Landed |
| Infinite / load-more | useInfiniteQuery |
infiniteQuery (recent) |
useSWRInfinite |
:infinite true + :next-page-param — one scoped feed entry accumulates an ordered page vector; a causal :rf.resource/load-more extends it; :rf.resource/items is the merged read (numbered/cursor pages stay ordinary resources with :keep-previous?) |
Landed |
| Keep-previous-data while paging | placeholderData: keepPreviousData |
n/a (manual) | keepPreviousData |
:keep-previous? on the route/resource; :rf.resource/state projects :previous-data / :previous-key |
Landed |
| SSR / hydration | dehydrate / HydrationBoundary |
getRunningQueries + preload |
fallback data | per-request frames; blocking route resources are the render wait point; allowlist projection serialized + hydrated under freshness rules; fresh hydrated entries are not re-fetched | Landed |
| Devtools / observability | React Query Devtools | RTK devtools (Redux) | external | Xray Resources panel + a :rf.resource/* / :rf.mutation/* trace family; static registry, live instance table, work-ledger table, route/resource graph, scope-audit + orphaned-owner lints |
Landed |
| Normalized / GraphQL cache | normalizr (external) | partial | external | Apollo/Relay-class — not a resources concern; transport is HTTP-only this phase | Out of scope |
| Offline persistence / cross-tab | persister plugins | n/a | external | not built; held for a later slice | Deferred (later slice) |
Every "Landed" claim above is grounded in Spec 016 — Resources and the reference implementation under implementation/resources/. The rest of this page expands the rows that carry the most surprise for someone arriving from a query library.
Where the cache lives, and why it is per-frame¶
A query library keeps one cache for the running app: TanStack Query's QueryClient, RTK Query's store slice, SWR's module cache. That is fine for a single-user SPA, and a liability the moment two principals share a process — most sharply on the server, where one Node process renders for every user at once.
re-frame2 puts the cache in the framework-owned runtime partition of each frame — one running instance of your app — at :rf.runtime/resources, never in your app-db. Two consequences fall out:
- SSR cannot leak by construction. Each server request renders in its own frame, so there is no process-global cache to bleed user A's articles into user B's render. A query library reaches the same safety only by spinning up a fresh client per request and being careful never to share it.
- An ordinary event handler can't corrupt the cache. It lives in the runtime partition, not app-db, so a careless
associn a:dbhandler can't wipe it. You read it through subscriptions and change it only through events.
This is the Server state: resources "cache you don't own" model. It is different by design, not merely a different default.
Scope: the leak boundary the query libraries leave to you¶
This is the single biggest difference, and the row most worth internalising.
In every query library the viewer's identity is a convention: you remember to put the user id (or tenant, locale, impersonation marker) into the queryKey. Forget it once and one user silently reads another's cache — a bug with no error and no stack trace, surfacing only as a support ticket.
re-frame2 makes scope the first, required segment of the cache key and refuses to default it:
;; Adapted from spec/016-Resources.md — the registration gate
(rf/reg-resource :realworld/feed
{:params-schema [:map [:page {:optional true} [:maybe :int]]]
:scope {:from-db :realworld/session} ;; REQUIRED — no default
:request (fn [{:keys [page]} _ctx] ...)
:tags (fn [_ _] #{[:feed]})})
- A
reg-resourcewith no:scopeis a loud registration error (:rf.error/resource-missing-scope-policy) — "I forgot this read is user-scoped" is unrepresentable. - A genuinely public read says so:
:scope :rf.scope/globalis an auditable claim, enumerated by Xray's scope-audit surface as the security-review list. - A subscription that cannot resolve a scope raises
:rf.error/resource-sub-unresolved-scope— it never falls through to a shared read or a silent permanent:idle. - When the viewer changes mid-session (login, logout, account switch), a
{:from-db …}subscription re-keys reactively to the new principal's entry, reading the new key's:idle/:loadingand never the old principal's data.
The query libraries can do scoped caching; nothing stops you putting the user id in the key. re-frame2's difference is that it fails closed — the boundary is enforced, not advised. See The scoped key: a leak boundary that fails closed.
Owners and causes, not observers¶
Query libraries have one idea — the observer: a mounted hook keeps a query alive, and an unmounted one lets it become GC-eligible. re-frame2 splits that single idea into two that never blur:
- An owner is a liveness lease. Routes own resources for as long as the route is active; machines own them for the actor's lifetime; an app can mint an explicit
[:lease …]with a matching release. Owners decide GC eligibility and whether invalidation refetches now or just marks stale. - A cause is an explanation — "why did this fetch happen?" (route entry, click, invalidation, focus return). Causes change nothing about liveness; they exist so the trace can answer why.
The practical payoff: opening a devtool never pins or refetches a resource. A query library's devtools panel is itself an observer-ish surface; Xray, by contract, never becomes an owner by observing (Spec 016 §Active owners and causes). It also means a "background sync" that should keep data warm with no UI mounted is a first-class, explicit lease here, rather than a staleTime: Infinity + always-mounted hack.
Views never fetch — the structural inversion¶
useQuery fetches on mount. That is the defining ergonomic of the query libraries and the defining difference of re-frame2: a re-frame2 view reads a subscription and never triggers a fetch. The fetch is caused by a route entry, an event, or a machine.
;; The view is passive — it reads, it does not fetch.
(rf/reg-view article-page [slug]
(let [state @(subscribe [:rf.resource/state
{:resource :realworld/article :params {:slug slug}}])]
(cond
(:loading? state) [article-skeleton]
(and (:error state) (not (:has-data? state))) [article-error (:error state)]
:else [article-view (:data state)])))
The cleanest cause is the page itself, declared as route metadata:
(rf/reg-route :realworld/article
{:path "/articles/:slug"
:resources [{:resource :realworld/article
:params (fn [route] {:slug (get-in route [:params :slug])})
:blocking? true}]})
This is not a missing feature (the data still loads on navigation); it is the inversion that lets SSR get a natural wait point (:blocking? true), lets a route own and release the resource deterministically, and keeps views pure. If you find yourself wanting "fetch when this component appears," the re-frame2 answer is "make the route or an event the cause" — see Routes declare what a page needs.
Projection: no select, because subscriptions already do it¶
TanStack's select (and RTK's selectFromResult) lets a component derive a slice of the cached value without re-rendering on unrelated changes. re-frame2 has no :select key — and that is a structural advantage, not a gap. A projection over a resource is an ordinary subscription layered over [:rf.resource/data …], with the full subscription graph's caching and parametric inputs behind it:
(rf/reg-sub :article/title
:<- [:rf.resource/data {:resource :realworld/article :params {:slug "welcome"}}]
(fn [article _] (:title article)))
You get memoisation, composition with other subs, and reuse across views for free — none of which a per-call select function gives you (Spec 016 §No :select key).
Invalidation is a declared consequence, not a remembered call¶
In TanStack and SWR, keeping reads honest after a write is an imperative call you remember to make in onSuccess (invalidateQueries / mutate). RTK Query is closer to re-frame2 here with its providesTags / invalidatesTags. re-frame2 takes the declarative-tag model further: invalidation is a declared consequence of the named mutation, recorded on the event record, scoped by default, and with a precise three-rung cross-scope lattice:
(rf/reg-mutation :realworld/favorite
{:params-schema [:map [:slug :string]]
:scope :rf.scope/global
:request (fn [{:keys [slug]} _ctx] ...)
:invalidates (fn [{:keys [slug]} _result]
[{:scope :rf.scope/global ;; global facts
:tags #{[:article slug] [:article-list]}}
{:scope {:from-db :realworld/session} ;; this viewer's feed
:tags #{[:feed]}}])})
The fail-closed floor matters: a bare :rf.resource/invalidate-tags with no scope is a loud error, not a silent global blast across every tenant. "Invalidate this tag wherever it lives" is possible — :cross-scope? true — but it is an audited operation that must carry a :cause and is a privacy-relevant trace event. See Writes invalidate by tag — causally and Spec 016 §Scoped invalidation descriptors.
Optimistic updates with rollback — landed¶
The query libraries all ship optimistic UI: flip the cache immediately, snapshot the prior value, revert on server rejection (onMutate/onError rollback in TanStack, updateQueryData + undo patch in RTK, optimisticData + rollbackOnError in SWR). re-frame2 now matches this on the mutation path, with one deliberate divergence on contested rollback.
A mutation declares an optimistic plan applied before the request leaves, in one of two forward forms — the twins of the success-time consequence keys:
:optimistic—(fn [params] -> {target patch-fn}), the exact-target twin of:patches. Anilpatch-fn removes the entry optimistically; a patch over an absent key seeds it.:optimistic-tags—(fn [params] -> [{:scope … :tags #{…} :patch (fn [old] new)}]), the tag-addressed twin of:invalidates. It patches every cached entry carrying the tag in its scope — the cross-view-consistency case (flip a favorite and have it flip on the detail, every list, and the session feed at once) you can't enumerate by exact key.
(rf/reg-mutation :realworld/favorite
{:params-schema [:map [:slug :string]]
:scope :rf.scope/global
:request (fn [{:keys [slug]} _ctx] ...)
:optimistic-tags (fn [{:keys [slug]}]
[{:scope :rf.scope/global
:tags #{[:article slug]}
:patch (fn [article] (update-favorite article true))}])
:populates (fn [{:keys [slug]} result]
{{:resource :realworld/article :params {:slug slug}} result})
:invalidates (fn [{:keys [slug]} _result]
[{:scope :rf.scope/global :tags #{[:article slug] [:article-list]}}
{:scope {:from-db :realworld/session} :tags #{[:feed]}}])})
Three properties are worth holding onto, because they are where re-frame2 differs from a hand-rolled onMutate snapshot:
- The runtime records the inverse — you don't. It snapshots each touched entry verbatim before patching, so a rollback restores exactly what existed, never an author-written undo patch that can drift from the forward change.
- Settle is deterministic, with no wall-clock race. It is decided on recorded facts — the generation acceptance verdict and a per-entry
:revision. An accepted:okreply commits (the authoritative:populates/:patchesoverwrite the optimistic value, then:invalidatesruns); an accepted:error/:cancelledrolls back; a stale/superseded reply rolls back nothing. - Contested rollback is governed by
:on-conflict, and the default diverges from the query libraries. When a concurrent write landed on the entry between the apply and a failure, the recorded inverse is a stale "before." The default:invalidatedeclines to restore it and marks the entry stale so the read path refetches the authoritative value — the read path is the recovery authority, re-frame2's deliberate divergence from TanStack/SWR's unconditional context restore.:forcerestores the inverse anyway (the single-writer escape, with a tooling warning).
A view renders the in-flight optimistic state from the instance sub's derived :optimistic? flag (true between the pre-request apply and settle). The optimistic surface is exact-key or tag-within-named-scope only, both fail-closed on a nil-resolving {:from-db …} scope — there is no scope-agnostic optimistic write, so it cannot leak across viewers, tenants, or SSR requests. The normative contract is Spec 016 §Optimistic mutations; the worked write is in Part 4 of the tutorial.
Polling and infinite feeds — both landed¶
The two parity-relevant features once tracked as gaps here have since landed; they are recorded below for readers arriving from the query libraries.
Polling / refetch-interval — landed (EP-0020)¶
TanStack's refetchInterval, RTK's pollingInterval, and SWR's refreshInterval make timed background refetching a one-line option. re-frame2 ships the same as :poll-interval-ms on reg-resource (Spec 016 §Polling): a positive interval in ms that revalidates the entry while it is actively owned and the tab is visible — the third member of the freshness-timer family beside :stale-after-ms / :gc-after-ms. It is owner-gated by design: an owner-free entry never polls, so opening a devtool or leaving a stale tab cannot drive background traffic. (This is in addition to the focus/reconnect revalidation that landed earlier via install-revalidation-listeners!.)
Infinite / load-more feeds — landed (EP-0021)¶
useInfiniteQuery (TanStack) and useSWRInfinite accumulate pages into one growing list with cursor management. re-frame2 ships this as a first-class :infinite resource (Spec 016 §Infinite resources and load-more feeds): a resource registered with :infinite true plus a pure :next-page-param derivation (returning nil is the single terminal). One scoped feed entry accumulates an ordered page vector; a causal :rf.resource/load-more event extends it; the merged flat list is the headline read at :rf.resource/items, with :rf.resource/pages for boundaries and :rf.resource/infinite-state for the combined view-model. Numbered and cursor pagination stay exactly as before — every page is an ordinary resource keyed by its params, with :keep-previous? to avoid skeleton flashes (Spec 016 §Paginated and previous data) — the infinite kind is the complementary accumulating-feed model, not a replacement.
The honest gaps — out of scope, on purpose¶
These remain deliberately outside this HTTP-only phase. Do not let the rest of this page's confidence obscure them.
- Normalized / GraphQL caches (Apollo, Relay, normalizr). The transport is HTTP-only this phase; a normalized entity cache is a separate later artefact gated on a GraphQL phase, not a resources gap (Spec 016 §What Spec 016 does NOT cover).
- Offline persistence and cross-tab broadcast. Deferred later slices; not in the public-beta contract.
When to reach for resources at all¶
A query library is the obvious default in React because it is the only server-state machinery on offer. In re-frame2 it is one tool among several, and not always the right one:
Simpler than a resource
- A handful of reads, no caching story. A managed HTTP request plus a small app-db slice is less machinery and entirely idiomatic.
- Login and other commands. Auth is a state machine driving a write — model it as a machine, not a cached read.
Reach for resources when cached server reads start multiplying and the per-read bookkeeping — scope, staleness, dedupe, invalidation, GC, SSR — is worth moving into the framework. Where should this value live? has the decision table.
The summary, one more time:
- Matched: keyed cache, staleness, dedupe, GC, tag invalidation, mutations + consequences, optimistic updates with rollback, focus/reconnect revalidation, active-owner polling, infinite / load-more feeds, keep-previous paging, SSR/hydration, devtools — all landed.
- Different by design: per-frame cache, required structural scope, owners-vs-observers, passive views, subscriptions-instead-of-
select, declarative causal invalidation,:on-conflict :invalidateas the contested-rollback default. - Out of scope (deliberately): normalized/GraphQL caches, offline/cross-tab — held for later slices.
You can now:
- map any TanStack Query / RTK Query / SWR feature onto its re-frame2 resource counterpart, and read each row's landed / different-by-design / out-of-scope status
- explain the "same problem, different physics" line — a cache wired to causes, not to component lifecycle — and why scope is a required structural key
- decide when a resource is the wrong tool, reaching instead for managed HTTP or a machine