The model¶
A resource keeps a cached copy of server data. Routes and events ask for it; views read the result. This separation lets several views share one request without any view deciding when to fetch.
The application has three jobs: register how to read, cause a load, and render the cached state. A mutation describes a write and which cached reads it changes.
Register a resource¶
;; cf. examples/real-apps/realworld_resources/resources.cljs
(ns app.articles
(:require [re-frame.core :as rf]
[re-frame.http.managed]
[re-frame.resources]
[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]} _data] #{[:article slug]})}
(fn [{:keys [slug]} _ctx]
{:request {:method :get :url (str "/api/articles/" slug)}
:decode :json}))
This assumes the endpoint returns {:article {...}}, with the same response
for every viewer. :params-schema, which is optional, describes the values identifying the read;
:scope describes who can share its cached answer. The request function is
the third argument and returns managed-HTTP args.
Registration itself sends nothing.
:stale-after-ms 60000 keeps a successful response fresh for one minute.
Without that key, only explicit invalidation makes it stale. Becoming stale
does not start a request; the next ensure can refresh it.
The optional Resources artefact must be loaded, and it brings managed HTTP,
its transport, with it. Missing re-frame.resources raises
:rf.error/resources-artefact-missing at registration.
Params validation also needs the schemas artefact.
The resource reference lists
all registration options.
Cause a fetch¶
For page data, let the route declare what it needs:
(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")
Entering this route ensures the article. A fresh entry is a cache hit; an
in-flight load is shared; otherwise an HTTP request starts. :blocking? true
keeps :rf.route/transition at :loading until the first load settles. The
route commits immediately, so its view can render a loading state. A failed
first load makes the transition :error; a successful retry restores :idle.
The same declaration gives SSR a wait point.
Events can load data independently of navigation. Here an open preview keeps the entry alive until it closes:
(rf/reg-event :article/preview-opened
(fn [_ [_ slug]]
{:fx [[:dispatch [:rf.resource/ensure
{:resource :app/article :params {:slug slug}
:owner [:article/preview slug]
:cause [:event :article/preview-opened]}]]]}))
(rf/reg-event :article/preview-closed
(fn [_ [_ slug]]
{:fx [[:dispatch [:rf.resource/release-owner
{:owner [:article/preview slug]}]]]}))
An owner keeps an entry alive. A cause explains why the request happened in traces and Xray. Reading a subscription adds neither.
Project: five statuses¶
The route above and this view complete the read path. The application shell
renders article-page when :rf.route/id is :app/article:
(rf/reg-view article-page []
(let [slug (:slug @(subscribe [:rf.route/params]))
query {:resource :app/article :params {:slug slug}}
state @(subscribe [:rf/resource query])]
(cond
(= :idle (:status state)) [:p "Waiting for the article load."]
(:loading? state) [:p "Loading article…"]
(:error state)
[:div
[:p "Could not load the article."]
[:button {:on-click #(dispatch [:rf.resource/refetch query])} "Retry"]]
:else
[:article
[:h1 (get-in state [:data :article :title])]
[:p (get-in state [:data :article :body])]
(when (:fetching? state) [:p "Refreshing…"])
(when (:refresh-error state) [:p "Could not refresh; showing saved data."])
[:button {:disabled (:fetching? state)
:on-click #(dispatch [:rf.resource/refetch query])}
"Refresh"]])))
This view rebuilds the route's params because its Retry and Refresh buttons
dispatch the query. A view that only reads what its route planned names the
read by resource id instead: @(subscribe [:rf/route-resource :app/article])
reads the identity the route ensured, so a restated params map cannot drift
from it. A route that plans no such read projects :status :unplanned; one
that plans the resource under two identities raises
:rf.error/resource-route-read-ambiguous.
| Status | Meaning | Useful UI |
|---|---|---|
:idle |
No load has started, or a first load was cancelled | Placeholder |
:loading |
First load in flight, no data | Loading indicator |
:loaded |
Data is available, possibly stale | Content or an empty-result message |
:fetching |
Refresh in flight, keeping existing data | Content and a small progress indicator |
:error |
First load failed | Error and retry action |
:unresolved |
The scope resolver returned nil, so the read has no identity yet |
A sign-in prompt or a restoring line |
:unplanned |
A [:rf/route-resource …] read whose route does not plan the resource |
Usually nothing: the page is not the route's |
The cell below runs the registration, route and view from this page against
a stub transport, and shows the raw status above the view. The draft link's
answer is a 503, so its first load ends in :error; Retry fails the same way,
because the stub answers a URL the same way every time. It answers at once, so
:loading and :fetching pass too quickly to see.
(require '[re-frame.core :as rf]
'[re-frame.http.managed]
'[re-frame.resources]
'[re-frame.routing]
'[re-frame.http.stub-transport :as stub-transport])
;; A stub transport answers this frame's requests: "hello" loads, "draft" fails
;; with a 503. A real app installs none, and its requests go to the server.
(stub-transport/install-transport! :rf/default
(stub-transport/route-transport
{[:get "/api/articles/hello"] {:body {:article {:title "Hello" :body "The first article."}}}
[:get "/api/articles/draft"] {:status 503}}))
(rf/reg-resource :app/article
{:params-schema [:map [:slug :string]]
:scope :rf.scope/global
:stale-after-ms 60000
:tags (fn [{:keys [slug]} _data] #{[:article slug]})}
(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 (:slug @(subscribe [:rf.route/params]))
query {:resource :app/article :params {:slug slug}}
state @(subscribe [:rf/resource query])]
(cond
(= :idle (:status state)) [:p "Waiting for the article load."]
(:loading? state) [:p "Loading article…"]
(:error state)
[:div
[:p "Could not load the article."]
[:button {:on-click #(dispatch [:rf.resource/refetch query])} "Retry"]]
:else
[:article
[:h1 (get-in state [:data :article :title])]
[:p (get-in state [:data :article :body])]
(when (:fetching? state) [:p "Refreshing…"])
(when (:refresh-error state) [:p "Could not refresh; showing saved data."])
[:button {:disabled (:fetching? state)
:on-click #(dispatch [:rf.resource/refetch query])}
"Refresh"]])))
;; The application shell: two links and the resource's raw status.
(rf/reg-view shell []
(let [slug (:slug @(subscribe [:rf.route/params]))
state @(subscribe [:rf/resource {:resource :app/article :params {:slug slug}}])]
[:div
[:nav
[rf/route-link {:to :app/article :params {:slug "hello"}} "hello"] " · "
[rf/route-link {:to :app/article :params {:slug "draft"}} "draft"]]
[:p "Status: " [:code (pr-str (:status state))]]
[article-page]]))
[rf/frame-root {:id :rf/default
:initial-events [[:rf.route/navigate {:to :app/article :params {:slug "hello"}}]]}
[shell]]
A failed refresh keeps :loaded and the data, and sets :refresh-error.
:error is reserved for a failed first load. Both fields hold a
managed-HTTP failure; branch on
its :kind when different failures need different messages. Retries are
opt-in in the request args; an error does not retry itself by default.
The query must use the same params and scope as the cause. A valid but
different key has its own entry, which stays :idle until ensured. Narrow
subscriptions such as :rf.resource/data read one field when a view does not
need the whole state.
Scope: whose cache?¶
A cache key is [scope resource-id canonical-params]. :rf.scope/global
asserts that everyone gets the same answer. If authentication, tenant, locale
or permissions affect it, include those distinctions in a named scope resolver:
(rf/reg-resource-scope :app/session
{:inputs {:username [:db [:auth :user :username]]}}
(fn [{:keys [username]} _ctx]
(when username [:rf.scope/session {:username username}])))
;; On a viewer-dependent resource registration:
;; :scope {:from-db :app/session}
Routes and subscriptions inherit that policy. A resolver returning nil
means the read has no identity yet: a subscription reads no entry and
reports :status :unresolved, and an ensure raises
:rf.error/resource-scope-unresolved-reference. Give a view an
:unresolved arm (a sign-in prompt, a restoring line), and wait until the
identity is known before loading that resource. Scope selects a cache entry;
your server still authenticates and authorizes the request.
When the resolver's inputs change, a subscription reads the new key without
fetching. A change of identity — login, logout, an account switch — is one
event, dispatched from the handler that commits the new identity:
:rf.resource/change-principal clears the scopes the departing user held,
then replans the current route under the new identity. Resolve the departing
scopes from the event's db, which still describes who is leaving:
(rf/reg-event :auth/logout
(fn [{:keys [db]} _]
{:db (dissoc db :auth)
:fx [[:dispatch [:rf.resource/change-principal
{:from [(rf/resolve-resource-scope db :app/session)]
:cause :logout}]]]}))
A nil in :from — a resolver with nothing to name — is skipped. The clear
matters as much as the replan: an old entry that something still owns would
otherwise be refetched on focus or a poll tick with the new user's
credentials. The scope tutorial handles
session restoration and logout with it.
Owners, causes, refetch rules¶
A route releases its owner on leave. A machine using [:machine actor-id]
as owner releases it on actor destruction. Your own owner, as in the preview
example, needs a matching release-owner event.
Owned entries survive GC and refetch when invalidated. Once its last owner
is released, an entry stays cached for :gc-after-ms (default five minutes)
and is then collected unless something owns or is loading it, so returning
within that window reuses the cached data.
An ownerless ensure is useful for warming the cache. It loads normally but keeps nothing alive. A subscription is always passive, even while a view remains mounted.
refetch always starts a new request and supersedes any old one for the key.
Releasing the last owner aborts in-flight work where possible. A cancelled
first load returns to :idle; a cancelled refresh keeps its data at :loaded.
Cancellation sets neither resource error field. Late replies cannot replace
newer cache data.
Use :poll-interval-ms for data that changes regularly: it polls while owned
and visible. Set :revalidate-on #{:focus :reconnect} on the frame to refresh
stale owned entries when the user returns or the network reconnects. Both
are optional; polling and revalidation
describe the timing rules.
Continue after a read¶
An editor may need a fetched article copied into a draft once. Add :reply-to
to the ensure rather than making the subscription dispatch:
(rf/reg-event :editor/opened
(fn [_ [_ slug]]
{:fx [[:dispatch [:rf.resource/ensure
{:resource :app/article :params {:slug slug}
:reply-to [:editor/article-loaded]}]]]}))
(rf/reg-event :editor/article-loaded
(fn [{:keys [db]} [_ {:keys [status value]}]]
(if (= :ok status)
{:db (assoc-in db [:editor :draft] (:article value))}
{})))
The reply arrives once for the accepted attempt, including a fresh cache hit.
It contains :status and :value or :error; cancellation uses :cancelled.
The view still renders loading and failure from the resource subscription.
For editors that can close or switch articles before the reply arrives,
carry a visit id and check it before changing the draft, as the
mutation tutorial
does for saves.
Mutations invalidate by tag¶
A mutation declares the cache consequences of a server write. Resources tag their data; mutations name the tags they change. Owned matches refetch now, while unowned matches become stale for the next ensure.
(rf/reg-mutation :app/save-article
{:params-schema [:map [:slug :string] [:title :string]]
:scope :rf.scope/global
:invalidates (fn [{:keys [slug]} _result] #{[:article slug]})}
(fn [{:keys [slug title]} _ctx]
{:request {:method :put :url (str "/api/articles/" slug)
:body {:article {:title title}}
:request-content-type :json}
:decode :json}))
;; Dispatch from a handler or a view's captured dispatch:
;; [:rf.mutation/execute {:mutation :app/save-article
;; :params {:slug "hello" :title "Hello"}
;; :instance [:editor/save "hello"]}]
The view watches [:rf/mutation {:instance [:editor/save "hello"]}] for
:pending?, :success? and :error. Retrying uses the same execute command;
clearing the instance dismisses its settled state. The
mutation recipe shows tag invalidation
and direct updates; optimistic updates makes a
small write visible before confirmation.
Advanced¶
A route can declare several resources. :id / :after order their ensure
dispatches; they do not wait for earlier data. When one read needs another's
result, use a completion event to compute and ensure the dependent read.
Numbered pagination caches one entry per page; load-more grows one entry. Resource entries live in runtime-db, alongside mutation instances and work records; ordinary application handlers never edit those tables directly. SSR hydrates eligible entries and applies the same scope and freshness rules.
Troubleshooting¶
| Symptom | Check | Fix |
|---|---|---|
The view stays :idle |
Did a route or event ensure this exact key? | Match resource, params and scope in the cause and read |
| Invalidation refreshes nothing | Do tags and scope match, and is the entry owned? | Use the read's scope and attach an owner for active data |
| Cache grows after panels close | Is each app-created owner released? | Dispatch release-owner on close |
| A session switch leaves an idle page | Re-keying is passive | Replan the active route after changing identity |
Route transition is :error |
Read :rf.route/error |
Retry a failed blocking read, or fix a failed resource plan |
Errors and warnings maps each named failure to a recovery. For a one-off request whose result belongs in app-db, use managed HTTP directly instead of adding a cache.