Async resources¶
The resources module handles registered reads, cache identity, mutation
status and invalidation. The HTTP module supplies its managed transport. A
Fresco view reads those facts with
h/sub like any other subscription. This page shows the view side: loading
todos from /api/todos, per-todo write status, optimistic updates, merging a
late save reply into a newer draft, and loading data only while a view needs
it.
Load todos into a view¶
Add the resources and HTTP artefacts alongside Fresco (see the resource setup), then require both namespaces below. Register the read once, cause it from an event, and read its state in a view:
(ns app.todos.resources
(:require [re-frame.core :as rf]
[re-frame.resources]
[re-frame.http.managed]
[re-frame.fresco :as h]))
(rf/reg-resource :todo/list
{:params-schema [:map]
:scope :rf.scope/global
:tags (fn [_ _] #{[:todos]})}
(fn [_params _ctx]
{:request {:method :get
:url "/api/todos"}
:decode :json}))
(rf/reg-event :todo/initialise
(fn [_ _]
{:fx [[:dispatch [:rf.resource/ensure
{:resource :todo/list
:params {}
:owner [:todo-page]
:cause [:todo/initialise]}]]]}))
(h/defview todo-page [_]
(let [{:keys [status data loading? fetching?
error refresh-error has-data?]}
(h/sub [:rf/resource {:resource :todo/list :params {}}])]
[:section.todos {:aria-busy (boolean (or loading? fetching?))}
(when (or error refresh-error)
[:div {:role "alert"}
[:p (if has-data?
"Could not refresh. Showing the last saved list."
"Could not load todos.")]
[:button {:type "button"
:disabled (boolean (or loading? fetching?))
:on-click [:rf.resource/refetch
{:resource :todo/list
:params {}
:cause [:todo/retry]}]}
"Try again"]])
(cond
loading? [:p "Loading…"]
(= :idle status) [:p "Waiting to load todos."]
(not has-data?) nil
(empty? data) [:p "No todos yet."]
:else
[:ul.todo-list
(for [{:keys [id title]} data]
[:li {:key id} title])])]))
[:rf/resource …] returns the entry's :status, :data and :error, plus
derived flags: :loading? for a first load with no data, :fetching? for a
refresh, :stale? and :has-data?. Status is one of :idle, :loading,
:loaded, :fetching or :error.
A failed refresh keeps the cached :data and reports :refresh-error instead
of replacing the list with a first-load error. Keep that data visible and offer
retry. The refresh error remains during the retry and clears on success.
A subscription never fetches. It projects the cache, so a view that only
subscribes reads :idle for ever. An event causes the fetch, here the frame's
:todo/initialise through [:rf.resource/ensure …]. This :todo/initialise
replaces the one that seeded :todos into app-db: the server now owns the
list, so the seed event asks for it instead. Data that the current URL
determines is better caused by the route's :resources, which SSR and
transition blocking can also see.
The cell below runs :todo/list, :todo/initialise and todo-page against
a stubbed /api/todos, so nothing goes over the network. The frame does not
list :todo/initialise in :initial-events; a button dispatches it instead.
The view says Waiting to load todos. until you press it, because rendering
alone fetches nothing.
(require '[re-frame.core :as rf]
'[re-frame.resources]
'[re-frame.http.managed]
'[re-frame.http.test-support :as http-test-support]
'[re-frame.fresco :as h])
;; Stand-in server: GET /api/todos answers from this map.
(http-test-support/install-managed-request-stubs!
{[:get "/api/todos"] {:reply {:ok [{:id 1 :title "Buy milk"}
{:id 2 :title "Walk the dog"}]}}})
(rf/reg-resource :todo/list
{:params-schema [:map]
:scope :rf.scope/global
:tags (fn [_ _] #{[:todos]})}
(fn [_params _ctx]
{:request {:method :get
:url "/api/todos"}
:decode :json}))
(rf/reg-event :todo/initialise
(fn [_ _]
{:fx [[:dispatch [:rf.resource/ensure
{:resource :todo/list
:params {}
:owner [:todo-page]
:cause [:todo/initialise]}]]]}))
(h/defview todo-page [_]
(let [{:keys [status data loading? fetching?
error refresh-error has-data?]}
(h/sub [:rf/resource {:resource :todo/list :params {}}])]
[:section.todos {:aria-busy (boolean (or loading? fetching?))}
(when (or error refresh-error)
[:div {:role "alert"}
[:p (if has-data?
"Could not refresh. Showing the last saved list."
"Could not load todos.")]
[:button {:type "button"
:disabled (boolean (or loading? fetching?))
:on-click [:rf.resource/refetch
{:resource :todo/list
:params {}
:cause [:todo/retry]}]}
"Try again"]])
(cond
loading? [:p "Loading…"]
(= :idle status) [:p "Waiting to load todos."]
(not has-data?) nil
(empty? data) [:p "No todos yet."]
:else
[:ul.todo-list
(for [{:keys [id title]} data]
[:li {:key id} title])])]))
(h/defview resource-demo [_]
[:div
[:button {:type "button" :on-click [:todo/initialise]} "Load todos"]
[todo-page]
[:p "Status: "
(pr-str (:status (h/sub [:rf/resource {:resource :todo/list :params {}}])))]])
[h/frame-root {:id :app
:fx-overrides {:rf.http/managed :rf.http/managed-test-stub}}
[resource-demo]]
Track writes per instance¶
A write is a registered mutation, executed with an instance id that names the unit whose status the UI displays. Here each todo's checkbox has its own:
(rf/reg-mutation :todo/set-done
{:params-schema [:map [:id :int] [:done? :boolean]]
:scope :rf.scope/global
:invalidates (fn [_params _result] #{[:todos]})}
(fn [{:keys [id done?]} _ctx]
{:request {:method :put
:url (str "/api/todos/" id)
:body {:done done?}}
:decode :json}))
(h/defview done-checkbox [{:keys [id done?]}]
(let [save (h/sub [:rf/mutation {:instance [:todo/set-done id]}])]
[:input {:type :checkbox
:checked done?
:disabled (:pending? save)
:on-change [:rf.mutation/execute
{:mutation :todo/set-done
:params {:id id :done? (not done?)}
:instance [:todo/set-done id]
:cause [:click :todo/set-done]}]}]))
The event vector carries the mutation, params, instance and cause as data. The
instance subscription provides :pending?, :success?, :error?,
:settled?, :optimistic?, :result, and :error. On success the mutation
invalidates the [:todos] tag, so :todo/list refetches. Focused
subscriptions such as [:rf.mutation/pending? {:instance …}] return one of
those values.
Different instances settle independently. Re-executing the same instance
supersedes its pending attempt, so the older reply cannot update the cache or
run its continuation. A settled error stays until another execute replaces it
or [:rf.mutation/clear {:instance …}] dismisses it; clear also aborts work
still in flight, best-effort. Supersession does not order writes at the server.
The checkbox above is disabled while pending to avoid overlapping writes from
that control.
So the instance id is a policy choice. [:todo/set-done id] lets each todo
save concurrently; [:todo/set-done] would make every checkbox share one
status and cancel each other's pending writes.
Optimistic updates and rollback¶
To show the change before the server replies, declare an optimistic patch on the mutation. The runtime snapshots each entry carrying the tag, applies the patch before the request, and settles the snapshot on success, error, or cancellation:
(rf/reg-mutation :todo/set-done
{:params-schema [:map [:id :int] [:done? :boolean]]
:scope :rf.scope/global
:optimistic-tags
(fn [{:keys [id done?]}]
[{:scope :rf.scope/global
:tags #{[:todos]}
:patch (fn [todos]
(mapv #(if (= id (:id %)) (assoc % :done? done?) %)
todos))}])
:invalidates (fn [_params _result] #{[:todos]})}
(fn [{:keys [id done?]} _ctx]
{:request {:method :put
:url (str "/api/todos/" id)
:body {:done done?}}
:decode :json}))
On error or cancellation, the runtime restores the snapshot; you never write
an inverse patch. On success, this mutation invalidates [:todos], causing
owned list entries to refetch. The optimistic value stays visible until that
read returns. To put the mutation reply directly into the cache, declare
:populates on the mutation.
If another write changes the entry between the patch and settlement, the
default :on-conflict :invalidate does not restore the old snapshot over newer
data. It marks the entry stale so a refetch resolves the conflict. Use
:on-conflict :force only when overwriting concurrent changes is intended. The
instance reports :optimistic? true until the guess settles.
Merge a settled reply into the current draft¶
A save reply can arrive after the user has changed the draft again. Writing the whole server value over the draft deletes those edits; dropping the reply loses the server's normalization. Two mechanisms cover the two races:
- Supersession is enforced by the runtime. A reply from an older attempt under the same instance is suppressed and never reaches the continuation.
- Settle-merge is application policy. A current reply is compared with the params that were sent, field by field, so newer draft values keep their slots.
This is the :todo/save mutation the Forms editor executes. It
creates a todo when the draft has no :id and updates one when it does:
(rf/reg-mutation :todo/save
{:params-schema [:map
[:id {:optional true} :int]
[:title :string]
[:notes :string]]
:scope :rf.scope/global
:invalidates (fn [_params _result] #{[:todos]})}
(fn [{:keys [id] :as todo} _ctx]
{:request {:method (if id :put :post)
:url (if id (str "/api/todos/" id) "/api/todos")
:body {:todo todo}}
:decode :json}))
The Forms editor's :todo.editor/replied resets the editor after a successful
save. An editor that stays open after saving points :reply-to at a
settle-merge event instead:
[:rf.mutation/execute
{:mutation :todo/save
:params draft
:instance :todo.editor/save
:reply-to [:todo.editor/save-settled]}]
The reply includes the accepted :params, so the continuation knows what was
sent:
(rf/reg-event :todo.editor/save-settled
(fn [{:keys [db]} [_ {:keys [status value params error]}]]
(if (= :ok status)
{:db (-> db
(assoc-in [:todo.editor :baseline] (:todo value))
(update-in
[:todo.editor :draft]
(fn [draft]
(reduce-kv
(fn [d field sent]
(if (= (get d field) sent)
(assoc d field (get-in value [:todo field]))
d))
draft
params))))}
{:db (assoc-in db [:todo.editor :save-error] error)})))
A field that still equals the sent value takes the server's version. A field
edited after the request keeps the new draft value. :reply-to runs only after
the runtime has accepted the reply as current, applied its cache effects, and
settled the instance.
The Forms recipes implement touched-field display and submit
gating with ordinary events and subscriptions. Settle-merge protects the draft
from the server reply; forms/buffered-field is a separate single-field tool.
Cancellation and supersession¶
| Concern | Handled by | Result |
|---|---|---|
| An older write reply loses to a newer attempt under the same instance | Runtime | The older reply is suppressed; its continuation never runs |
| An older resource fetch loses to a newer generation of the same identity | Runtime | The stale reply cannot update the cache |
| A user dismisses an error or abandons a write | Application | Dispatch mutation clear; status clears and in-flight work is aborted best-effort |
| A view no longer needs a resource | Application | Release the owner that caused the read; a request no owner needs is aborted best-effort |
| Which attempts compete | Application | The instance id and resource params define the race |
Unmounting a screen does not cancel its writes, because a mutation may still matter after navigation. Cancel through the domain event that owns the write.
Cause a view-scoped read from an event¶
Most reads belong to the page, but some exist only while one part of the
screen is showing: search suggestions, picker options, a hover preview. Those
still need a cause. The event that decides the data is wanted ensures the
read, and the event that dismisses it releases it (:todo/search is
registered in the next section):
(rf/reg-event :todo.search/wanted
(fn [_ [_ q]]
{:fx (cond-> [[:dispatch [:rf.resource/release-owner
{:owner [:todo.search]}]]]
(seq q)
(conj [:dispatch [:rf.resource/ensure
{:resource :todo/search
:params {:q q}
:owner [:todo.search]
:cause [:todo.search/wanted q]
:keep-previous? true}]]))}))
(rf/reg-event :todo.search/dismissed
(fn [_ _]
{:fx [[:dispatch [:rf.resource/release-owner {:owner [:todo.search]}]]]}))
- An owner keeps an entry alive until it is released.
:owneris any value you choose; releasing it drops that owner from every entry it holds. The release above runs before the ensure, so the previous query stops being owned as the new one starts. - Release does not delete the entry. The entry then follows its
:gc-after-mspolicy, and a later ensure can rejoin it while it is warm. - Ensures share work. Concurrent ensures of the same identity share one
request, and ensuring an entry still fresh under
:stale-after-msserves the cached value without fetching. - Other policies stay in their own layers. Debounce, staleness, refresh
with previous data, supersession, and transport cancellation are each set
where they belong.
:keep-previous?goes on the ensure, not the subscription.
An owner that is never released keeps its entry out of garbage collection, which is why the dismiss event exists.
Search example¶
Register the resource with its cache policies:
(rf/reg-resource :todo/search
{:params-schema [:map [:q :string]]
:scope :rf.scope/global
:stale-after-ms 30000
:gc-after-ms 60000}
(fn [{:keys [q]} _ctx]
{:request {:method :get
:url "/api/todos/search"
:params {:q q}}
:decode :json}))
Keep each controlled keystroke synchronous and debounce only the committed query the resource uses. A generation number stops an old timer from committing:
(rf/reg-sub :todo.search/text
(fn [db _]
(get-in db [:todo.search :text] "")))
(rf/reg-sub :todo.search/committed-q
(fn [db _]
(get-in db [:todo.search :committed-q] "")))
(rf/reg-event :todo.search/input
(fn [{:keys [db]} [_ text]]
(let [gen (inc (get-in db [:todo.search :gen] 0))]
{:db (-> db
(assoc-in [:todo.search :text] text)
(assoc-in [:todo.search :gen] gen))
:fx [[:dispatch-later
{:ms 250
:event [:todo.search/settle gen]}]]})))
(rf/reg-event :todo.search/settle
(fn [{:keys [db]} [_ gen]]
(if (= gen (get-in db [:todo.search :gen]))
(let [q (get-in db [:todo.search :text])]
{:db (assoc-in db [:todo.search :committed-q] q)
:fx [[:dispatch [:todo.search/wanted q]]]})
{})))
(rf/reg-event :todo.search/clear
(fn [{:keys [db]} _]
{:db (update db :todo.search assoc
:text ""
:committed-q ""
:gen (inc (get-in db [:todo.search :gen] 0)))
:fx [[:dispatch [:todo.search/dismissed]]]}))
The result view reads the cache. The previous results it shows while a new
query loads come from :previous-data, which the ensure's :keep-previous?
fills:
(h/defview search-results [{:keys [q]}]
(let [{:keys [data previous-data loading? fetching? error refresh-error]}
(h/sub [:rf/resource {:resource :todo/search :params {:q q}}])
shown (or data previous-data)]
[:section.search-results
{:aria-busy (boolean (or loading? fetching?))}
(when (or error refresh-error)
[:p {:role "alert"}
"Search failed. "
[:button {:type "button"
:disabled (boolean (or loading? fetching?))
:on-click [:rf.resource/refetch
{:resource :todo/search
:params {:q q}
:cause [:todo.search/retry q]}]}
"Try again"]])
(cond
(and loading? (not shown)) [:p.hint "Searching…"]
(nil? shown) nil
(empty? (:todos shown)) [:p "No matches."]
:else
[:ul
(for [{:keys [id title]} (:todos shown)]
[:li {:key id} title])])]))
(h/defview todo-search [_]
(let [text (h/sub [:todo.search/text])
q (h/sub [:todo.search/committed-q])]
[:div.search
[:input {:type :search
:value text
:aria-label "Search todos"
:placeholder "Search todos"
:on-input [:todo.search/input ::h/value]
:on-key-down {"Escape" [:todo.search/clear]}}]
(when (seq q)
[search-results {:q q}])]))
What happens as the user types:
- Each keystroke updates
:todo.search/textimmediately and increments the generation. No fetch starts yet. - After 250 ms of quiet, only the current generation commits
qand dispatches:todo.search/wanted, whose ensure (not the list mounting) starts the fetch. An empty query only releases the previous owner. - A new committed query releases the old identity and ensures the new one.
The old request is aborted best-effort once no owner needs it, and a late
reply is suppressed.
:keep-previous? trueprojects the previous query's results at:previous-data, with:previous? true, while:loading?reports the new query's first load. - Escape dispatches
:todo.search/clear, which dispatches:todo.search/dismissedto release the owner. Nothing infers that from the view disappearing. - Repeating a query before its
:gc-after-msexpiry rejoins the cached entry, and if it is still fresh under:stale-after-msno request is made.
Xray shows which owners hold an entry and which cause started a fetch. On the server nothing dispatches the ensure, so the client's own events cause the first fetch after hydration.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
A resource stays :idle |
Nothing caused it; a subscription never fetches | Give it a cause: route :resources, a prefetch, or [:rf.resource/ensure …] from the event that decides the data is wanted |
Boot raises :rf.error/resources-artefact-missing |
The resources model was not loaded | Require re-frame.resources, normally with re-frame.http.managed |
| Search results flash empty on every new query | Each params value is a separate cache entry, and previous data is not kept | Set :keep-previous? true on the [:rf.resource/ensure …], render (or data previous-data), and use :loading? with :previous? for the in-flight state |
| Old query results replace new ones | Fetching was done outside the resource model | Register the resource and let the runtime suppress stale replies |
| A resource read throws after render | h/sub escaped into a callback, promise, timer, or deferred sequence |
Read during the synchronous body and keep the resulting value |
| Every checkbox becomes pending together | All writes share one instance id | Include the todo's id, such as [:todo/set-done id] |
| A save reply erases keystrokes typed after submission | The continuation overwrote the whole draft | Settle-merge against the accepted :params |
| Optimistic rollback overwrites a concurrent update | The conflict policy forces the old snapshot | Keep the default :on-conflict :invalidate unless forced rollback is a business rule |
| A dismissed or unmounted view still receives a mutation reply | Mutations are not cancelled by view lifetime | Choose an instance and an explicit clear policy |
When an event is the wrong cause¶
| Job | Better cause |
|---|---|
| Data required by the current URL | Route :resources, including SSR and transition blocking |
| Warming data before any view needs it | [:rf.route/prefetch address] from a link's :on-mouse-enter, or an explicit ensure |
| Manual refresh | A [:rf.resource/refetch …] event |
| One-off uncached request | Managed HTTP |
| Drafts, field validation, and submit gating | Forms module |
| Multi-stage workflow | A state machine that causes the resource reads and transitions |
For readers coming from TanStack Query
A resource is close to a query, but a TanStack query fetches because a
component rendered useQuery, whereas a Fresco subscription only reads
the cache and an event causes the fetch. Debounce is event policy,
staleness and GC stay on the registration, and supersession follows
explicit identities. Mutations invalidate declared tags rather than
relying on a later invalidateQueries call.