Skip to content

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 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 and HTTP artefacts must both be loaded. Missing re-frame.resources raises :rf.error/resources-artefact-missing at registration; missing re-frame.http.managed raises :rf.error/http-artefact-missing when the first request starts. 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"]])))
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

The cell below runs the registration, route and view from this page against canned replies, and shows the raw status above the view. The draft link's reply is a 503, so its first load ends in :error; Retry fails the same way, because a canned reply never changes. The stub 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.test-support :as http-test-support])

;; Canned replies: "hello" loads, "draft" fails with a 503.
(http-test-support/install-managed-request-stubs!
  {[:get "/api/articles/hello"] {:reply {:ok {:article {:title "Hello" :body "The first article."}}}}
   [:get "/api/articles/draft"] {:reply {:failure {:kind :rf.http/http-5xx :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 :app
                :initial-events [[:rf.route/navigate {:to :app/article :params {:slug "hello"}}]]
                :fx-overrides {:rf.http/managed :rf.http/managed-test-stub}}
 [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 raises :rf.error/resource-sub-unresolved-scope on a subscription and :rf.error/resource-scope-unresolved-reference on an ensure. Wait until the identity is known before reading or 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. If the route stays the same, dispatch [:rf.route/replan-resources {:cause :account-changed}] after committing the new identity. The scope tutorial handles session restoration, logout and that replan together.

Resolve a departing user's scope from the event's db before removing the identity, then pass the concrete value to clear-scope:

(rf/reg-event :auth/logout
  (fn [{:keys [db]} _]
    (let [old-scope (rf/resolve-resource-scope db :app/session)]
      {:db (dissoc db :auth)
       :fx (cond-> []
             old-scope
             (conj [:dispatch [:rf.resource/clear-scope
                               {:scope old-scope :cause :logout}]]))})))

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 unowned and no longer loading, an entry can be collected at the next :gc-after-ms check (default five minutes). The check is armed when a load settles; it does not promise five full minutes of retention after the owner leaves.

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.