The model¶
Server state is data your app does not own — a declared, inspectable cache, not a private fetch inside each view. This page is the core model: register a read, cause a fetch, project five statuses, scope the cache, and declare writes that invalidate by tag.
To build Conduit end to end, use the tutorial. Task recipes: paginate, invalidate after a mutation.
Coming from TanStack Query?
Keep the mental model of a keyed cache with staleness and invalidation. Three
deliberate differences show up below: views never fetch; scope is a required key
axis; invalidation is declared on the mutation, not an onSuccess call you
remember. Full mapping: Coming from TanStack Query.
Optional artefact
Require re-frame.resources (and usually re-frame.http.managed) once at boot —
Maven coordinate day8/re-frame2-resources. Forget the require and the first
reg-resource / reg-mutation throws :rf.error/resources-artefact-missing.
An app with one or two uncached reads is often happier with
managed HTTP alone.
The cache you don't own¶
A resource answers five questions that SPAs usually re-decide per feature: where the copy lives, when it is stale, who may refetch, how concurrent readers share one request, and how logout stops a cross-user leak.
That cache lives in runtime-db (path
:rf.runtime/resources), not app-db. Ordinary handlers cannot
wipe it by accident. You change it only through events
and read it through subscriptions.
Register a resource¶
A resource is a subscription you read and a cause you fire — two different jobs.
;; cf. examples/real-apps/realworld_resources/resources.cljs
(ns app.resources
(:require [re-frame.core :as rf]
[re-frame.http.managed]
[re-frame.resources]))
(rf/reg-resource :realworld/article
{:params-schema [:map [:slug :string]]
:scope :rf.scope/global} ;; REQUIRED — whose cache?
(fn [{:keys [slug]} _ctx]
{:request {:method :get
:url (str "/api/articles/" slug)}
:decode :json}))
Strict three-slot grammar: (reg-resource id metadata request-fn). Putting the
request fn in the metadata map is :rf.error/resource-bad-spec.
| Metadata key | Role |
|---|---|
:params-schema |
Required. Malli schema of params — the read's identity |
:scope |
Required. :rf.scope/global, {:from-db resolver-id}, or :rf.scope/from-caller |
:tags |
(fn [params data] #{…}) — facts this data is about (for invalidation) |
:stale-after-ms |
Freshness window; next ensure refetches after this |
:gc-after-ms |
Lifetime after last owner leaves (default 5 min; :never to pin) |
:poll-interval-ms |
Clocked re-read while owned and tab visible |
:infinite |
true → load-more feed kind (paginate how-to) |
The request fn describes the domain request only. It must not set
:request-id, :on-success, or :on-failure — the runtime owns reply addressing
(stale-reply suppression). Cross-cutting headers live in reg-http-interceptor.
reg-resource does not fetch. It only teaches the runtime how to.
Cause a fetch¶
The cleanest cause is the page. Route metadata :resources means "this page needs
this server state":
;; cf. examples/real-apps/realworld_resources/routing.cljs
(rf/reg-route :realworld/article
{:params [:map [:slug :string]]
:resources [{:resource :realworld/article
:params (fn [route] {:slug (get-in route [:params :slug])})
:blocking? true}]}
"/articles/:slug")
On entry the runtime ensures the resource with the route as owner; on leave it
releases. :blocking? true holds the transition until the read settles (also an SSR
wait point).
Other causes use the same entry with a different cause recorded for the trace:
;; Explicit ensure from a handler (app-minted event owner — release on leave)
(rf/dispatch [:rf.resource/ensure
{:resource :realworld/article
:params {:slug "hello"}
:owner [:article/opened :article-page]
:cause [:event :article/opened]}])
;; Pull-to-refresh — new generation, no owner
(rf/dispatch [:rf.resource/refetch
{:resource :realworld/article
:params {:slug "hello"}
:cause [:manual :article/refresh]}])
Coming from TanStack Query?
Views never fetch. A route or event causes the load; the view only reads. That is what lets the same view render on the server, in a test, or on a cache hit.
Project: five statuses¶
(rf/reg-view article-page [slug]
(let [state @(rf/subscribe [:rf/resource {:resource :realworld/article
:params {:slug slug}}])]
(cond
(= :idle (:status state)) [article-placeholder]
(:loading? state) [article-skeleton]
(and (:error state) (not (:has-data? state))) [article-error (:error state)]
:else
[:<>
(when (:fetching? state) [refresh-indicator])
(when (:refresh-error state) [refresh-warning (:refresh-error state)])
[article-view (:data state)]])))
:status |
Meaning | Show |
|---|---|---|
:idle |
No load attempted | Placeholder |
:loading |
First load, no usable data | Skeleton |
:fetching |
Refresh while prior data stays | Data + quiet indicator |
:loaded |
Usable data (maybe stale) | Data |
:error |
First load failed | Error |
Invariants. :error is first-load only — a failed background refresh keeps
:loaded and records :refresh-error. Freshness is orthogonal to status. Prefer
the booleans (:loading?, :has-data?, …) over re-deriving rules from :status.
No subscription ever fetches
Missing cause ⇒ permanent :idle / skeleton. You are missing a route
:resources or an ensure, not a sub.
Three lanes¶
| Lane | Spelling | Who |
|---|---|---|
| Register | reg-resource / reg-mutation |
Author at boot |
| Cause | route :resources, ensure / execute events |
Routes, handlers, machines |
| Project | [:rf/resource …] and friends |
Views |
Narrower projections ([:rf.resource/data …], [:rf.resource/status …], …) re-render
only when that slice changes. Commands include
ensure, refetch, invalidate-tags, release-owner, clear-scope, remove —
full list in the API.
Scope: whose cache?¶
Cache identity is a triple: [scope resource-id canonical-params].
:rf.scope/global— same answer for every viewer (explicit claim).{:from-db resolver-id}— viewer-relative; resolver pure over declared:inputs.:rf.scope/from-caller— every ensure/sub must supply:scopeor fail loud.
(rf/reg-resource-scope :realworld/session
{:inputs {:username [:db [:auth :user :username]]}}
(fn [{:keys [username]} _ctx]
(when username
[:rf.scope/session {:username username}])))
(rf/reg-resource :realworld/feed
{:params-schema [:map [:page {:optional true} [:maybe :int]]]
:scope {:from-db :realworld/session}
:tags (fn [_ _] #{[:feed]})}
(fn [{:keys [page]} _ctx]
{:request {:method :get
:url "/api/articles/feed"
:params {:limit 10 :offset (* 10 (dec (or page 1)))}}
:decode :json}))
Nil resolution fails closed — no silent shared read. Logout clears a scope:
(rf/reg-event :auth/logout
(fn [{:keys [db]} _]
(let [old-scope (rf/resolve-resource-scope db :realworld/session)]
{:db (dissoc db :auth)
:fx (cond-> []
old-scope
(conj [:dispatch [:rf.resource/clear-scope
{:scope old-scope :cause :logout}]]))})))
Resolve the old scope before stripping auth from db.
Coming from TanStack Query?
Scope is a required structural axis, not a key segment you assemble by hand and sometimes forget.
Owners, causes, refetch rules¶
- Owner — a liveness hold (route, machine, app-event owner). Controls GC and whether invalidation refetches now or only marks stale.
- Cause — why this fetch happened (trace / Xray). Does not keep the entry alive.
| Rule | Behaviour |
|---|---|
| Ensure of a fresh entry | Cache hit |
| Ensure while in flight | Join the existing request |
| Explicit refetch | New generation; supersedes in-flight |
| Cancel vs stale reply | Abort if possible; generation check always suppresses stale replies |
Focus revalidation is opt-in: (rf/install-revalidation-listeners! frame-id) —
refetches only entries that are stale and still owned.
Polling is a registration key — owner-driven, pauses when the tab is hidden:
(rf/reg-resource :dashboard/build-status
{:scope :rf.scope/global
:params-schema [:map [:repo :string]]
:poll-interval-ms 5000
:tags (fn [_ _] #{[:build]})}
(fn [{:keys [repo]} _ctx]
{:request {:method :get :url (str "/repos/" repo "/build")}
:decode :json}))
Three freshness tools, three questions:
| Tool | Question |
|---|---|
:poll-interval-ms |
Changes on its own — keep fresh on a clock |
| Focus revalidation | User came back — refresh stale owned data |
Mutation :invalidates |
This write made that read wrong |
Routes with several resources¶
Each :resources entry may carry :params, :scope, :blocking?, :when,
:keep-previous? (show prior page while the next loads), and :id / :after
(order ensure dispatch, not data waterfalls). Full recipe for pages:
Paginate a feed.
Mutations invalidate by tag¶
A mutation is a named write. On success it
invalidates the tags it broke — declared once, not
remembered in onSuccess:
;; cf. examples/real-apps/realworld_resources/mutations.cljs
(rf/reg-mutation :realworld/favorite
{:params-schema [:map [:slug :string]]
:scope :rf.scope/global
:invalidates (fn [{:keys [slug]} _result]
[{:scope :rf.scope/global
:tags #{[:article slug] [:article-list]}}
{:scope {:from-db :realworld/session}
:tags #{[:feed]}}])}
(fn [{:keys [slug]} _ctx]
{:request {:method :post
:url (str "/api/articles/" slug "/favorite")}
:decode :json}))
Success plan arms (fixed order): :patches → :populates → :removes →
:invalidates. Patches run before populates, so when the same key is both
patched and populated the populate wins — it is applied last, overwriting the
patch. Invalidation runs last of all. Only keys this same mutation populated
are spared from its immediate refetch — a populate is an authoritative load, so
the value it just wrote stays fresh. A patched key is not exempt: the same
pass may still mark it stale and refetch it.
Execute and watch (instance id is app-chosen — reuse it for the sub):
(rf/dispatch [:rf.mutation/execute
{:mutation :realworld/favorite
:params {:slug "hello"}
:instance [:ui :favorite "hello"]
:cause [:click :article/favorite]}])
@(rf/subscribe [:rf/mutation {:instance [:ui :favorite "hello"]}])
;; => {:status :pending …} then :success / :error
Scope footgun. Invalidation matches only entries in the scopes you name. Wrong scope ⇒ silent miss (dev warning). Recipe, populate/patch arms, and optimistic writes: Invalidate after a mutation.
Coming from TanStack Query?
Invalidation is causal — a declared consequence of the mutation, visible on the event record.
Troubleshooting¶
Registration and use-time errors fail closed (missing scope policy, bad request shape, unresolved scope on sub, …). There is no path from "forgot the viewer" to "served another user's cache." Named ids live in the API and error catalogue; testing turns the same failures into assertions.
| Symptom | Signal | Fix |
|---|---|---|
Permanent :idle / skeleton |
No cause fired | Route :resources or [:rf.resource/ensure …] |
:rf.error/resource-missing-scope-policy |
Scope omitted on reg-resource |
Add :scope (:rf.scope/global or a resolver) |
:rf.error/resource-sub-unresolved-scope |
Scope resolver returned nil |
Resolve only when logged in, or don't subscribe |
:rf.warning/resource-sub-scope-mismatch |
Sub scope ≠ active ensure scope | One named resolver for register, route, and sub |
| Invalidation refreshes nothing | Wrong scope on :invalidates |
Name the matching scope per descriptor; watch the dev warning |
:rf.error/resources-artefact-missing |
Forgot the require | (:require [re-frame.resources]) at boot |
A complete read loop¶
Register → route causes → view projects. Copy-paste skeleton:
(ns app.articles
(:require [re-frame.core :as rf]
[re-frame.resources]
[re-frame.http.managed]
[re-frame.routing]))
(rf/reg-resource :app/article
{:params-schema [:map [:slug :string]]
:scope :rf.scope/global
:stale-after-ms 60000
:tags (fn [{:keys [slug]} _] #{[:article slug] [:article-list]})}
(fn [{:keys [slug]} _ctx]
{:request {:method :get :url (str "/api/articles/" slug)}
:decode :json}))
(rf/reg-route :app/article
{:params [:map [:slug :string]]
:resources [{:resource :app/article
:params (fn [route] {:slug (get-in route [:params :slug])})
:blocking? true}]}
"/articles/:slug")
(rf/reg-view article-page []
(let [slug (get @(rf/subscribe [:rf.route/params]) :slug) ;; or your route projection
state @(rf/subscribe [:rf/resource {:resource :app/article
:params {:slug slug}}])]
(cond
;; :idle — nothing has caused a load yet (no route :resources / ensure hit)
(= :idle (:status state)) [placeholder]
;; :loading — first load, no usable data yet
(:loading? state) [skeleton]
;; :error — first load failed, still no data (a failed *refresh* keeps :loaded)
(and (:error state) (not (:has-data? state))) [error-panel (:error state)]
;; :loaded / :fetching — usable data; a background refresh keeps it visible
:else
[:<>
(when (:fetching? state) [refresh-indicator]) ;; refetching with data
[article-body (:data state)]])))
Advanced (elsewhere)¶
| Topic | Where |
|---|---|
| Numbered pages & infinite feeds | Paginate a feed |
| Optimistic UI, patches, populate | Invalidate after a mutation |
| SSR / hydration of the cache | SSR concepts + tutorial Part 2 |
| Full RealWorld build | Tutorial |
| Prove the cache in tests | Testing |
When resources are the wrong tool¶
| Situation | Prefer |
|---|---|
| One-off uncached call | Managed HTTP |
| Client-only state | app-db |
| Named stage machine | Machines |
| No server yet | app-db + events |
Cached server reads that multiply are the reason to reach for this artefact — not every network call.