re-frame.resources¶
Use resources to load server data, cache it and read it from views. You register a read once, with its params schema, scope and request; an event, route or machine then dispatches [:rf.resource/ensure …] to load it, and views read the cached result through the [:rf/resource …] subscription, which never fetches. The runtime deduplicates concurrent requests, tracks staleness, refetches after invalidation, garbage-collects entries nothing owns and hydrates the cache after SSR; a mutation is the matching write, which invalidates or patches the reads it changed.
Use a resource when the same server data is read from more than one place, or needs caching, deduplication, staleness or a refetch after a write; use a mutation for a write that changes such data. A request whose reply one event handles, such as a login or a one-off load you keep in your own app-db, needs neither: issue :rf.http/managed directly.
Resources ship in the optional day8/re-frame2-resources artefact and use managed HTTP (day8/re-frame2-http) as their transport, so require both re-frame.resources and re-frame.http.managed once at boot; without the resources artefact, rf/reg-resource and the other resource functions throw :rf.error/resources-artefact-missing, and without re-frame.http.managed the first load or write throws :rf.error/http-artefact-missing.
(:require [re-frame.core :as rf]
[re-frame.http.managed] ;; registers the :rf.http/managed transport
[re-frame.resources]) ;; registers the resource events, subs and runtime
;; cf. examples/capabilities/resources/resources/core.cljs
;; Register the read once, at boot.
(rf/reg-resource :article/by-slug
{:params-schema [:map [:slug :string]]
:scope :rf.scope/global}
(fn [{:keys [slug]} _ctx]
{:request {:method :get :url (str "/api/articles/" slug)}
:decode :json}))
;; Load it from an event. The owner keeps the entry alive until it is released.
(rf/reg-event :article/preview-opened
(fn [_ [_ slug]]
{:fx [[:dispatch [:rf.resource/ensure
{:resource :article/by-slug
:params {:slug slug}
:owner [:article/preview-opened slug]
:cause [:event :article/preview-opened]}]]]}))
(rf/reg-event :article/preview-closed
(fn [_ [_ slug]]
{:fx [[:dispatch [:rf.resource/release-owner
{:owner [:article/preview-opened slug]}]]]}))
;; Read it from a view. The subscription never fetches.
(rf/reg-view article-preview [{:keys [slug]}]
(let [state @(subscribe [:rf/resource {:resource :article/by-slug
:params {:slug slug}}])]
(cond
(= :idle (:status state)) [:p "Open an article to load it."]
(:loading? state) [:p "Loading…"]
(and (:error state) (not (:has-data? state))) [:p.error "Could not load the article."]
:else [:h2 (:title (:data state))])))
For page data, a route's :resources key is the usual cause: entering the route ensures each resource with the route as owner, and leaving releases it. Each entry names its :resource and may add :params (fn [route] params), :blocking?, :keep-previous?, :when (fn [route ctx] bool), a :scope override, and :id / :after to order the ensures; reg-route documents them.
Three terms recur on this page:
- Scope says whose data an entry holds. The cache key, the scoped key, is
[scope resource-id canonical-params], so readers with distinct scope values have distinct entries. Your declaration must include every identity distinction the response depends on. Every resource declares a scope policy; see Scope policy. - An owner is a value you choose, such as
[:article/preview-opened slug], that stands for something needing the entry: a route visit, a machine, an open panel. An owned entry is kept, and invalidation, focus and polling refetch it; once its last owner is released, it is garbage-collected within:gc-after-ms. A route releases its owner when you leave it, and a machine actor that ensures with the owner[:machine actor-id]has it released when the actor is destroyed. Every other owner needs a matching[:rf.resource/release-owner …]. Anensurewith no owner still loads, but nothing keeps the entry. - A cause is a value saying why a load or write happened, such as
[:event :article/preview-opened]. It is recorded in traces and Xray and has no other effect.
Each fetch of a scoped key starts a new generation, and each request attempt has a work id. A reply is written only while both still match the entry, so a late reply from a superseded, cancelled or removed request is discarded. This page calls that stale-reply suppression.
reg-resource, reg-mutation, reg-resource-scope, resolve-resource-scope, resource-state and mutation-state are called through the re-frame.core facade as rf/… (the three reg-* forms are macros there), and registrations are removed with rf/clear. Everything else is a keyword-addressed event or subscription.
The model teaches owners, causes, scope and invalidation.
Registration¶
reg-resource¶
- Kind: macro (
rf/reg-resource); also a function,re-frame.resources/reg-resource - Signature:
- Description: Registers a resource: a cached read that events load and subscriptions read. Returns
resource-id.metadatais the registration map: the required:scopeand:params-schema, plus the optional keys in The resource spec. A non-mapmetadataraises:rf.error/resource-bad-spec, as does a:requestkey inside it; the request fn belongs in the third slot.request-fnis(fn [params ctx] …)and returns a managed-HTTP args map.paramsare the canonical params.ctxisnil, except on an infinite resource, where it carries:rf.resource/page-paramand:rf.resource/page-indexfor the page being fetched.- Validates the combined spec (
:scopefirst, then:params-schema), then writes a:resource-kind registrar entry. - The spec stored under
:rf/resourceis the metadata map with:requestadded (Reading registrations). - See Register a resource.
- Example:
(rf/reg-resource :article/by-slug {:doc "Article detail by slug." :params-schema [:map [:slug :string]] ;; required: validates and canonicalizes params :data-schema :app/article ;; shape declaration for tooling; runtime validation is :decode :scope :rf.scope/global ;; required: an explicit, auditable claim :transport :rf.http/managed ;; the only transport :stale-after-ms 60000 :gc-after-ms 300000 :poll-interval-ms 5000 ;; optional: refetch every 5s while owned and visible :tags (fn [{:keys [slug]} _data] #{[:article slug]}) :sensitive? false} ;; required request fn (third argument): returns a managed-HTTP args map (fn [{:keys [slug]} _ctx] {:request {:method :get :url (str "/api/articles/" slug)} :decode :app/article}))
The resource spec¶
Required keys:
| Key | Notes |
|---|---|
:params-schema |
Validates and canonicalizes params. The canonical params identify the cache entry. Validation runs when re-frame.schemas is loaded; without it, params are canonicalized but not checked. Params must be portable EDN, so a fractional number fails: send 9.99 as 999 (cents) or "9.99". Instants are accepted. An omitted :params is {}; an explicit nil is validated as nil. A spec without :params-schema raises :rf.error/resource-bad-spec. |
:scope |
The scope policy: :rf.scope/global or {:from-db <resource-scope-id>} (see Scope policy). Any other value, or none, raises :rf.error/resource-missing-scope-policy. |
| request fn (third argument) | For :transport :rf.http/managed, returns a managed-HTTP args map. It must be a fn (or a Var), or registration raises :rf.error/resource-bad-spec. It must not supply :request-id, :on-success or :on-failure: the runtime supplies those from the scoped key and generation, and supplying one raises :rf.error/resource-reserved-request-key. |
Optional keys:
:doc:data-schema— a static declaration of the decoded data's shape, shown to tooling (the resource's:schemafact) and in the:rf/resourceregistration read. It is not checked at runtime; validate a response with the request's:decode.:transport—:rf.http/managed, the only transport and the default. Any other value registers, then raises:rf.error/resource-unknown-transportat the first load.:stale-after-ms— how long a loaded entry stays fresh, in milliseconds. Absent means it never goes stale by time alone; invalidation can still mark it stale.0means it is stale as soon as it loads. Any value other than a non-negative number,nilincluded, raises:rf.error/resource-bad-specat registration.:gc-after-ms— the interval of the GC check that removes an entry with no owner and no load in flight. The check's timer is armed when a load settles, not when the last owner is released. When it fires on an entry that is still owned or loading, it re-arms for another interval. So an idle entry is removed at most this long after its last owner is released, and possibly sooner. Absent defaults to300000(5 minutes);:neverkeeps it. Otherwise it must be a positive number of milliseconds;nil,0or any other value raises:rf.error/resource-bad-spec.:poll-interval-ms— the active-owner poll interval. See Polling. A value that is neither a number nornilraises:rf.error/resource-bad-spec.:timeout-ms— stamps each fetch's work-ledger record with:deadline-at, its:started-atplus this many milliseconds, which Xray's Resources panel shows. It is recorded, not enforced: nothing cancels a fetch at its deadline. Bound the request itself with the:timeout-msof the managed-HTTP args your request fn returns.:tags—(fn [params data] → #{tag …}), the tags thatinvalidate-tagsand mutations match.:infinite, plus the infinite-only keys:next-page-param,:prev-page-param,:page->items,:initial-page-paramand:refetch. See Infinite resources.:infinitetakes only the literaltrue; any other value raises:rf.error/resource-bad-spec. There is no:page-data-schema; supplying one raises the same error.:sensitive?/:large?— classify the whole entry. The SSR hydration payload withholds such an entry entirely, so neither its data nor its scope and params ride, and the client loads it again if its route still needs it. Off-box trace and epoch exports replace its scope and params with opaque tokens. The same properties on a schema affect only how validation failures are redacted.:sensitive/:large— per-path classification: a vector of paths rooted at:data,:paramsor:scope(a bare path means:data), e.g.{:sensitive [[:data :ssn]]}. A malformed declaration raises:rf.error/resource-bad-spec.
No other key changes how a resource loads or caches. Coming from TanStack Query maps TanStack options such as select and placeholderData. The mutation keys (:invalidates, :patches, :populates, :removes, :optimistic, :optimistic-tags, :on-conflict) belong on reg-mutation.
Scope policy¶
:scope is required and takes one of two shapes:
| Policy | Meaning |
|---|---|
:rf.scope/global |
The same params return the same data for every user, tenant, permission set, locale and impersonation state. Declaring it is an explicit claim that tooling can audit. |
{:from-db <resource-scope-id>} |
Compute the scope from app-db at use time with a resolver registered by reg-resource-scope. The resolver declares its :inputs, so tooling can show the derivation without running it. |
Anything else is a registration error: an app-namespaced keyword, a literal tuple, map or string, a fn, a misspelt :rf.scope/* keyword, or no :scope at all. A scope known only at the call site is modelled as {:from-db …} over an app-db slot the caller writes first, or passed as a concrete :scope at each use site.
The registered policy is the default. A route entry, subscription payload or event payload that omits :scope uses it; pass :scope at a use site only when that site reads as a different principal (an admin reading tenant X).
- Events resolve payload
:scope, then the route entry's:scope, then the registered policy. A{:from-db <id>}reference that resolves tonilat an event raises:rf.error/resource-scope-unresolved-reference. - A route entry's
:scopeis a concrete value or a{:from-db <id>}reference, never a function. At a route, a scope that cannot be resolved (a function ornilon the entry, or a reference on the entry or the registered policy that resolves tonil) fails the route's resource plan and never falls through to another tier::rf.route/errorreads:rf.error/resource-route-plan, with the original error's data under:cause. - Subscriptions resolve payload
:scope, then the registered policy. A{:from-db …}reference that resolves tonilraises:rf.error/resource-sub-unresolved-scope; the subscription never reads global data or returns:idleinstead.
See Scope: whose cache? in the guide.
reg-mutation¶
- Kind: macro (
rf/reg-mutation); also a function,re-frame.resources/reg-mutation - Signature:
- Description: Registers a mutation: a named write to the server that, on success, invalidates, patches, populates or removes cached resource entries. Returns
mutation-id. Run it with[:rf.mutation/execute …].metadatais the registration map::params-schema,:invalidates,:patches,:docand the other keys in The mutation spec. A non-mapmetadataraises:rf.error/mutation-bad-spec, as does a:requestkey inside it.request-fnis(fn [params ctx] …)and returns the managed-HTTP args for the write;ctxisnil. Writes use the same:rf.http/managedtransport as reads, and the runtime addresses replies and suppresses stale ones the same way.- Runtime state is keyed by mutation instance id, so concurrent submissions of the same mutation keep separate rows.
- Validates the combined spec and writes a
:mutation-kind registrar entry. The spec stored under:rf/mutationis the metadata map with:requestadded.
- Example:
(rf/reg-mutation :article/save {:params-schema :app/article ;; required: validates and canonicalizes params :scope :rf.scope/global ;; the cache scope the arms below target by default ;; On success, in this order: patches, populates, removes, then invalidates. ;; Patch transforms an entry's existing data: (fn [old-data result] → new-data). :patches (fn [{:keys [slug]} _result] {{:resource :articles/list :params {}} (fn [articles result] (mapv #(if (= slug (:slug %)) (assoc % :title (:title result)) %) articles))}) ;; Populate seeds an entry straight from the reply (the server returns the saved article). :populates (fn [{:keys [slug]} result] {{:resource :article/by-slug :params {:slug slug}} result}) ;; Invalidate marks the other reads carrying these tags stale. :invalidates (fn [{:keys [slug]} _result] #{[:article slug]}) :invalidate-timing :after-success} ;; | :before-request | :after-failure | :after-settle ;; required request fn (third argument): a managed-HTTP write (fn [{:keys [slug] :as article} _ctx] {:request {:method :put :url (str "/api/articles/" slug) :body article} :decode :app/article}))
The mutation spec¶
Required keys:
| Key | Notes |
|---|---|
:params-schema |
Validates and canonicalizes the write's params. A spec without it raises :rf.error/mutation-bad-spec. |
| request fn (third argument) | Returns the managed-HTTP args map for the write. It must be a fn (or a Var), or registration raises :rf.error/mutation-bad-spec. It must not supply :request-id, :on-success or :on-failure: the runtime supplies those from the instance and generation, and supplying one raises :rf.error/resource-reserved-request-key. |
A target names one cache entry as {:resource <id> :params <params> :scope <scope>}. Its :scope is a concrete scope, :rf.scope/global or a {:from-db <id>} reference, and defaults to the mutation's resolved scope (:rf.scope/same).
Optional keys:
-
:invalidates—(fn [params result] → tags-or-descriptors), what to mark stale on success. The runtime applies it as a scoped:rf.resource/invalidate-tags. Return either:- a collection of tags, e.g.
#{[:article slug]}or[[:article slug]], invalidated in the mutation's resolved scope (a lone[:article slug]vector also means one tag); or - one descriptor map or a vector of them,
{:scope … :tags #{…}}, each naming its own scope (the same scope forms as a target, above), so one write can invalidate both global and per-user reads. A descriptor may set:cross-scope? trueto invalidate the tags in every scope, and:refetch-populated? trueso this invalidation also refetches keys the same mutation populated, which otherwise stay fresh; use it when the reply carries only part of the record.
nilor an empty collection invalidates nothing. A malformed descriptor or a non-collection result raises:rf.error/mutation-invalid-invalidationwhen this arm runs, before anything is invalidated.resultisnilwhen:invalidate-timingruns:invalidatesbefore the request or after a failure. -:patches—(fn [params result] → {target patch-fn}), where eachpatch-fnis(fn [old-data result] → new-data). It transforms an entry that already has data, with the same structural sharing as the read path; a target with no data is left alone. -:populates—(fn [params result] → {target value}). It seeds the entry as ifvaluehad just loaded, sovaluemust have the resource's stored shape. -:removes—(fn [params result] → [target …]), entries to remove on success. A removed entry's in-flight request is aborted where possible. -:scope— the default cache scope for the arms above (see Scope below). -:invalidate-timing— when:invalidatesruns::after-success(default),:before-request(before the request is sent),:after-failure(only when the write fails) or:after-settle(either way). An absent ornilvalue uses:after-success; any other value raises:rf.error/mutation-bad-spec. -:transport(:rf.http/managed, the only transport; any other value raises:rf.error/resource-unknown-transportwhen the write runs),:doc. -:sensitive/:large— per-path classification of the instance row, in the same shape as onreg-resource:[:params …]and[:scope …]paths classify the instance's params and scope, and[:data …]or bare paths classify its:result(e.g.{:sensitive [[:params :token]]}). A malformed declaration raises:rf.error/mutation-bad-spec. - a collection of tags, e.g.
The success arms run in a fixed order: :patches, :populates, :removes, then :invalidates. When one key is both patched and populated, the populate wins, and a key this mutation populated is not refetched by its own invalidation; a patched key can be.
Patches, populates and removes run after the server has accepted the write, so a bad target is skipped and the other targets still apply, unless it would write under a wrong identity:
- A target whose
{:from-db …}reference resolves tonilis skipped. An:invalidatesdescriptor whose reference resolves tonillikewise invalidates nothing. - A target that is not a map, has a non-keyword
:resourceor names an unregistered resource is skipped with:rf.warning/mutation-target-skippedin development builds. - A target that would write under a wrong identity throws when the reply settles: params that are not portable EDN raise
:rf.error/mutation-invalid-target, and a misspelt or non-EDN:scoperaises:rf.error/resource-invalid-scopeor:rf.error/resource-non-edn-params.
Scope:
A mutation's :scope is optional, unlike a resource's. It resolves from the payload :scope, then the spec :scope, then :rf.scope/global, and is the default scope for the success arms. Either :scope may be a concrete scope or a {:from-db <id>} reference, resolved against the executing handler's app-db; a reference that resolves to nil raises :rf.error/resource-scope-unresolved-reference before the write starts.
The resolved scope must match the scope of the resources the write changes. A write against user-, tenant- or locale-scoped entries that omits :scope invalidates the :rf.scope/global cache instead and leaves the scoped entries stale. No error is raised; development builds emit :rf.warning/mutation-scope-mismatch when the tags matched nothing in the resolved scope but do match entries in another. Declare the scope on the spec, pass it on [:rf.mutation/execute …], or name it per descriptor in :invalidates (The scope footgun).
Optimistic keys (see Invalidate after a mutation and Mutations invalidate by tag):
:optimistic—(fn [params] → {target patch-fn}), applied before the request is sent. It has the shape of:patcheswithout theresultargument, since there is no reply yet: eachpatch-fnis(fn [old-data] → new-data), and anilpatch-fn optimistically removes the entry.:optimistic-tags—(fn [params] → [{:scope … :tags #{…} :patch patch-fn} …]), the tag-addressed form: each descriptor patches every cached entry carrying its tags, for keeping other views consistent.:on-conflict— what to do when a rollback is contested because another write changed the entry after the optimistic apply.:invalidate(default) marks the entry stale so it refetches the server's value;:forcerestores the snapshot anyway and emits:rf.warning/optimistic-force-clobber. An absent ornilvalue uses:invalidate; any other value raises:rf.error/mutation-bad-spec.
When both optimistic forms select the same entry, the exact :optimistic target wins; the entry is patched once. An exact patch over an absent entry receives nil and can seed that entry.
An :optimistic target is checked before the request is sent. One that is not a map, has a non-keyword :resource, names an unregistered resource or has params that are not portable EDN raises :rf.error/mutation-invalid-target, and the write is not sent. A malformed :optimistic-tags descriptor (not a map, :tags not a collection, or no :patch fn) is dropped with :rf.warning/optimistic-tags-descriptor-skipped in development builds; the other descriptors and the write go ahead.
There is no :rollback key. The runtime snapshots each touched entry, with its :revision, into the :rollback slot of the instance row's :patch-summary, then commits, rolls back or reconciles when the write settles. Combining an optimistic plan with :invalidate-timing :before-request is rejected at registration with :rf.error/mutation-optimistic-before-request.
:retry is not a reg-mutation key. Retries are opt-in: put :retry {…} in the managed-HTTP args the request fn returns, which the runtime passes to the transport unchanged. Reads work the same way (see Retry). Retrying stays explicit per request because re-sending a non-idempotent write after a slow reply writes it twice.
Clearing a registration¶
- Kind: function (the facade's
rf/clear) - Signature:
- Description: Removes a registration, for hot reload or teardown. Each form returns the id, including when nothing is registered under it. This is registration lifecycle, not cache invalidation: to change cached data, dispatch
:rf.resource/invalidate-tags,:rf.resource/removeor:rf.resource/clear-scope. There is noclear-resource,clear-mutationorclear-resource-scopefunction;cleartakes the kind.:resourcealso disposes the resource's runtime state in each affected frame. It releases owner indexes, cancels timers and host handles, aborts in-flight work where possible, suppresses late replies by generation, removes tag-index rows and emits a trace.:mutationremoves the registration only. To reset a mutation's runtime instance rows, dispatch[:rf.mutation/clear …].:resource-scoperemoves the registrar entry only; a resolver holds no per-frame state.
- Example:
Named scope resolvers¶
A scope resolver computes a cache scope from app-db, for data that differs per user, tenant or locale. Register it once with reg-resource-scope, then reference it as {:from-db <scope-id>} wherever a scope is accepted: resource registration, route resources, event-side ensure, subscriptions, invalidation descriptors, and populate, patch and remove targets. clear-scope takes a concrete scope, which you get from the same resolver with resolve-resource-scope. A reference resolves at use time against the frame's app-db; a nil result fails closed at every site that needs a scope and never becomes a global read.
reg-resource-scope¶
- Kind: macro (
rf/reg-resource-scope); also a function,re-frame.resources/reg-resource-scope - Signature:
- Description: Registers a named scope resolver under
scope-idand returnsscope-id.metadataholds the required:inputs {name [:db <rf-path>]}and an optional:doc.[:db <rf-path>](a concrete:rf/path) is the only input source;[:runtime …](route-derived scope) is reserved and rejected with:rf.error/resource-scope-source-reserved.resolve-fnis(fn [inputs ctx] → scope-or-nil). Its first argument is always the map of resolved inputs.ctxis reserved, and the runtime passesnil. The fn must be pure: no fetching, dispatching, state changes or reads of host state.- The returned scope must be concrete portable EDN. A misspelt
:rf.scope/*keyword,[:rf.scope/global]or a{:from-db …}map raises:rf.error/resource-invalid-scope, and a non-EDN value raises:rf.error/resource-non-edn-params, wherever the resolver runs, including inresolve-resource-scope. - To read the whole db, declare it as an input on the root path:
{:inputs {:db [:db []]}}. There is no bare-fn shorthand. The stored:whole-db?flag is derived from this declaration. - Missing
:inputs(empty or:doc-only metadata), non-map metadata, a malformed:inputsdescriptor, a:resolvekey inside the metadata map, or a non-fn third argument raises:rf.error/invalid-resource-scope-spec. - Writes a
:resource-scope-kind registrar entry holding the canonical spec and captured source coords.
- Example:
resolve-resource-scope¶
- Kind: function
- Signature:
- Description: Resolves the named resolver
scope-idagainst adbvalue and returns the canonical concrete scope, ornil. Use it in a handler that needs the concrete scope. At logout, resolve the old scope from the handler's coeffectdb, which still holds the logged-in state, and pass it to[:rf.resource/clear-scope …].- A pure function over the resolver registry, not an effect. It has no app-state, dispatch or trace side effects: it does not emit
:rf.resource/scope-resolved, which comes from the{:from-db …}, route-entry and mutation-settle resolution sites. - Throws
:rf.error/resource-scope-not-registeredwhen no resolver is registered underscope-id, so a misspelt reference never yields a silentnil. - Returns
nilwhen the resolver returnsnilfor this db. That is the unresolved condition, and the caller decides what it means; it is never an implicit global.
- A pure function over the resolver registry, not an effect. It has no app-state, dispatch or trace side effects: it does not emit
- Example:
;; the logout idiom: resolve the old scope from the coeffect db, ;; then clear that scope's whole cache so the next user can never read it (rf/reg-event :auth/logout (fn [{:keys [db]} _] (let [old (rf/resolve-resource-scope db :realworld/session)] ;; nil when nobody was logged in {:db (dissoc db :auth) :fx (cond-> [] old (conj [:dispatch [:rf.resource/clear-scope {:scope old :cause :logout}]]))})))
Resource events (map payloads)¶
Resource events take a single map payload. The events that name a :resource validate it:
- An unregistered
:resourceraises:rf.error/resource-not-registered. - Params that fail
:params-schemaraise:rf.error/resource-invalid-params. - Scope resolution fails closed: a
{:from-db …}reference that resolves tonilraises:rf.error/resource-scope-unresolved-reference, and one naming no registered resolver raises:rf.error/resource-scope-not-registered(see Scope policy). - Params and scope must be portable EDN: a fractional number, ratio, NaN, fn or other host object raises
:rf.error/resource-non-edn-params. A misspelt:rf.scope/*keyword, the wrapped[:rf.scope/global](write:rf.scope/global), or a{:from-db …}map where a concrete scope is required raises:rf.error/resource-invalid-scope. - When the request is built, a request fn that returns
:request-id,:on-successor:on-failureraises:rf.error/resource-reserved-request-key, a:transportother than:rf.http/managedraises:rf.error/resource-unknown-transport, and a missingre-frame.http.managedraises:rf.error/http-artefact-missing. Nothing is written to the cache.
[:rf.resource/ensure {…}]¶
- Kind: event
- Payload:
{:resource …}(required), plus optional:params(default{}),:scope(default: the registered policy; see Scope policy),:owner,:cause,:keep-previous?and:reply-to. - Description: Loads the resource instance for these params and scope, unless it is already loaded and fresh.
- While the same scoped key is in flight,
ensurejoins that request: it attaches the owner, records the cause and emits a dedupe trace. - On an already-
:loadedentry that is still fresh by policy, it does not fetch. It serves the cached value, attaches the owner and emits:rf.resource/cache-hit. :owneradds to the entry's active owners, which keep it alive.:causeis recorded in the trace and history.:keep-previous? trueis for paging and filtering. While this key has no data of its own, its:rf/resourceview-model also carries the most recently loaded data of the same resource and scope under other params (:previous? true,:previous-data), so the old page stays on screen while the new one loads. Nothing is copied into the new entry.:reply-tois an optional data-only event vector. The accepted terminal reply is appended to it and dispatched once, immediately on a cache hit, and never for a stale reply. The reply is the managed-HTTP reply map (:status:ok,:erroror:cancelled, with:valueor:error) plus:resource,:params,:scope,:resource/keyand:cache-hit?. For an infinite feed,:valueis the merged item list. Anensurethat joins an in-flight load adds its target to that load. When a refetch, poll, focus scan or invalidation supersedes the load, the target moves to the new attempt, so exactly one reply still arrives. A target that is not a non-empty vector with a keyword head raises:rf.error/reply-invalid-target, and one carrying a fn or other host object raises:rf.error/reply-non-data-target, before anything is written.- See Cause a fetch.
- While the same scoped key is in flight,
- Example:
[:rf.resource/refetch {…}]¶
- Kind: event
- Payload:
{:resource …}(required), plus optional:params(default{}),:scope(default: the registered policy),:owner,:causeand:reply-to. - Description: Forces a refresh. It always starts a new generation, even when a request is already in flight; the earlier request is marked superseded and aborted if possible, otherwise its reply is suppressed by work id and generation. A manual refresh usually passes a
:causeand no:owner.:reply-toworks as forensure. - Example:
[:rf.resource/invalidate-tags {…}]¶
- Kind: event
- Payload: one of two shapes: scoped
{:scope :tags :cause?}, or cross-scope{:cross-scope? true :tags :cause}with no:scope. - Description: Marks every entry whose tags intersect
:tagsas stale. Entries with an active owner refetch now; the others stay stale and refetch the next time something ensures them.:tagsis a set or vector of tags. A lone tag vector such as[:article slug]is read as that one tag.- Invalidation is scoped by default. A scoped payload without
:scoperaises:rf.error/resource-invalidate-scope-required. :scopeis a concrete scope or a{:from-db <id>}reference, resolved against the handler'sapp-dbcoeffect as forensure, so the handler does not need to resolve it first. A reference that resolves tonilraises:rf.error/resource-scope-unresolved-reference.- A cross-scope invalidation sets
:cross-scope? trueand carries no:scope. It ignores the scope filter within the receiving frame and is visible in Xray. It never reaches another frame's cache. It must carry:cause, or it raises:rf.error/resource-cross-scope-cause-required; supplying:scopeas well raises:rf.error/resource-cross-scope-scope-conflict. - A successful load replaces an entry's tags with the tags computed from the new data.
- Example:
;; after a write settles, mark the acting viewer's reads with these tags stale; ;; the {:from-db …} reference resolves against the handler's db, as for ensure [:rf.resource/invalidate-tags {:scope {:from-db :realworld/session} :tags #{[:article "welcome"]} :cause [:follow-author-detail-sync "welcome"]}]
[:rf.resource/release-owner {…}]¶
- Kind: event
- Payload:
{:owner …} - Description: Releases an owner from every entry it holds; an owner that holds nothing is a no-op. In-flight work is aborted only when no remaining owner needs it. Every app-minted owner needs a matching release; an orphaned owner keeps its entries alive, and Xray flags it.
- Example:
[:rf.resource/clear-scope {…}]¶
- Kind: event
- Payload:
{:scope :cause}, where:scopeis a concrete scope, never a{:from-db <id>}reference -
Description: Drops a whole scope's cache. Dispatch it on logout and on any account, tenant, permission, locale or impersonation change. It:
- removes (or marks unusable) every entry in the scope
- releases owners
- aborts the scope's in-flight requests where possible
- suppresses late replies by scope and generation
- emits explanatory trace rows
:scopemust be concrete.ensureandinvalidate-tagsresolve a{:from-db …}reference against their own handler's db, butclear-scopeis usually dispatched from a logout handler's:fxand so runs in the next event, where the resolver's inputs are already gone. Resolve the scope in the logout handler withresolve-resource-scopeand pass the result.A
{:from-db …}map on the payload raises:rf.error/resource-invalid-scope(:recovery :fix-scope) before anything is cleared. It is rejected rather than ignored because a map is also a valid literal scope, which would match nothing and clear nothing. - Example:
[:rf.resource/remove {…}]¶
- Kind: event
- Payload:
{:resource …}(required), plus optional:params(default{}) and:scope(default: the registered policy). - Description: Removes one resource instance's cache entry.
:scoperesolves as forensure. An in-flight request for the entry is aborted where possible, and its late reply is suppressed. - Example:
[:rf.resource/load-more {…}]¶
- Kind: event (infinite resources only; see Infinite resources)
- Payload:
{:resource …}(required), plus optional:params(default{}),:scope(default: the registered policy) and:cause. It takes no:owner. - Description: Loads the next page of an
:infinitefeed: the runtime computes the next page param from the last page with:next-page-param, fetches that page through the same managed transport and appends it to the feed's page vector. The loaded pages stay visible, and:rf.resource/fetching-next?is true until the page settles; Infinite resources says how:statusand the:fetching?flags read meanwhile.- When there is no next page (
:next-page-paramreturnednil), it is a no-op that emits a trace. - Before page 0 has loaded, or on a resource that is not
:infinite, it is a no-op that emits a trace; load the first page withensure. - A failed page keeps every loaded page and records the failure in
:rf.resource/page-error. Dispatchingload-moreagain retries the same page. - While any fetch of the feed is in flight, a page or a whole-feed refresh, another
load-morededupes and fetches nothing. - A supplied
:owneris ignored with the warning:rf.warning/resource-load-more-owner-ignored. Whatever first loaded the feed, usually the route, already owns its one entry, andload-morenever changes the owner set.
- When there is no next page (
- Example:
[:rf.resource/window-focused] / [:rf.resource/network-reconnected]¶
- Kind: event (no payload)
- Description: Scans the frame's stale entries that have an active owner and refetches them by policy. The frame's
:revalidate-onlisteners dispatch these; application code must not dispatch them. The refetch carries cause:focus(window-focused) or:reconnect(network-reconnected) and no owner, so it keeps nothing alive. Generation and stale-reply suppression protect against late replies. - Example:
Resource subscriptions (passive)¶
A resource subscription reads the cache and never fetches. It resolves the scope as described in Scope policy and raises :rf.error/resource-sub-unresolved-scope rather than reading global data or returning :idle.
It validates its payload as the events do, so an unregistered :resource (:rf.error/resource-not-registered), params that fail the schema or are not portable EDN (:rf.error/resource-invalid-params, :rf.error/resource-non-edn-params), an invalid scope (:rf.error/resource-invalid-scope) or an unregistered resolver (:rf.error/resource-scope-not-registered) throws when the view reads it. A valid key that nothing has ensured reads :status :idle.
[:rf/resource {:resource … :scope … :params …}] ;; the full view-model
[:rf.resource/data {…}] [:rf.resource/status {…}]
[:rf.resource/loading? {…}] [:rf.resource/fetching? {…}]
[:rf.resource/stale? {…}] [:rf.resource/error {…}]
[:rf.resource/refresh-error {…}] [:rf.resource/has-data? {…}]
[:rf.resource/previous-data {…}]
Each focused subscription returns the :rf/resource view-model key of the same name (:rf.resource/data returns :data, :rf.resource/previous-data returns :previous-data, …), so a view that reads one re-renders only when that value changes.
Read them with the ordinary subscribe; there is no separate read function. subscribe's {:frame <target>} option reads from an explicit frame.
@(rf/subscribe [:rf/resource {:resource :article/by-slug :params {:slug "hello"}}])
;; => {:status :loading …} …then {:status :loaded :data {…} …}
The :rf/resource view-model holds facts plus derived booleans:
{:status :idle ;; :idle | :loading | :fetching | :loaded | :error
:data <last-known-good-or-nil>
:error <first-load-error-or-nil> ;; failure map {:kind :rf.http/… …}
:refresh-error <background-refresh-error-or-nil> ;; failure map {:kind :rf.http/… …}
:loading? <bool> ;; first load, no usable data
:fetching? <bool> ;; refresh in flight, prior data visible
:stale? <bool> ;; freshness — orthogonal to load status
:has-data? <bool>
:previous? <bool>} ;; :keep-previous? projection — when true, also
;; :previous-key + :previous-data (the prior key's data)
:idlemeans nothing has ensured this key, or its first load was aborted, for example because its last owner was released while the request was in flight.:loadingis a first load with no usable data.:fetchingis a refresh in flight while prior data stays visible.:erroris a failed first load with no usable data.- A failed background refresh stays
:loaded, keeps the prior:dataand records:refresh-error. :errorand:refresh-errorhold the managed-HTTP failure map, so a view branches on its:kindas in Failure kinds. An aborted request settles as a cancellation and records neither.- An
:errorentry is never fresh, so the nextensureretries the load;refetchretries at once.
:stale?, :loading?, :fetching? and :has-data? are derived when the subscription runs and are never stored. See Project: five statuses in the guide.
The cell below prints the whole view-model for two keys, before and after an ensure. Its requests are answered by stubs in the same tick, so it never paints :loading: "hello" goes from :idle to :loaded, and "draft" to :error with the failure map.
(require '[re-frame.core :as rf]
'[re-frame.http.managed]
'[re-frame.resources]
'[re-frame.http.test-support :as http-test-support])
;; Canned replies: "hello" loads, and "draft" fails with a 503.
(http-test-support/install-managed-request-stubs!
{[:get "/api/articles/hello"] {:reply {:ok {:title "Hello"}}}
[:get "/api/articles/draft"] {:reply {:failure {:kind :rf.http/http-5xx :status 503}}}})
(rf/reg-resource :article/by-slug
{:params-schema [:map [:slug :string]]
:scope :rf.scope/global}
(fn [{:keys [slug]} _ctx]
{:request {:method :get :url (str "/api/articles/" slug)}
:decode :json}))
(rf/reg-view view-model [{:keys [slug]}]
(let [query {:resource :article/by-slug :params {:slug slug}}]
[:div
[:button {:on-click #(dispatch [:rf.resource/ensure
(assoc query :owner [:article/view-model slug])])}
(str "Ensure " slug)]
[:pre {:style {:white-space "pre-wrap"}}
(pr-str @(subscribe [:rf/resource query]))]]))
;; :fx-overrides sends this frame's requests to the stubs. A real app leaves it out.
[rf/frame-root {:id :app/articles :fx-overrides {:rf.http/managed :rf.http/managed-test-stub}}
[:div
[view-model {:slug "hello"}]
[view-model {:slug "draft"}]]]
Mutation events (map payloads)¶
[:rf.mutation/execute {…}]¶
- Kind: event
- Payload:
{:mutation …}(required), plus optional:params(default{}),:instance,:scope,:cause,:reply-toand:optimistic?. - Description: Runs a mutation.
:instanceis the instance id that keys all runtime state for this run, so two concurrent submissions keep distinct rows. Supply your own, such as:form/save-1or[:article/save slug], when a view reads the write's state through[:rf/mutation {:instance …}]. When omitted the runtime generates one, which you see only in the:reply-toreply and in traces.:scopeoverrides the spec's:scopeand takes the same forms; see Scope in The mutation spec.- On success the runtime patches, populates and removes resource entries, then invalidates tags, at the time
:invalidate-timingsets. :reply-tois an optional data-only event vector. When the write settles, the reply is appended to it and dispatched once, after the cache changes and the instance row have been written. It is the managed-HTTP reply map (:status:ok,:erroror:cancelled) plus:mutation,:params,:instance,:scope,:affected-keysand:cause([:mutation <mutation-id> <instance>], not the payload's:cause).:optimistic? falseruns a registered optimistic plan pessimistically for this call.- Before anything is written or sent: an unregistered
:mutationraises:rf.error/mutation-not-registered. Params that fail:params-schemaraise:rf.error/mutation-invalid-params, and params that are not portable EDN (a fractional number, a fn) raise:rf.error/resource-non-edn-params. An:instancethat is not portable EDN raises:rf.error/mutation-non-serializable-instance-id. A misspelt:rf.scope/*scope raises:rf.error/resource-invalid-scope, and a{:from-db …}naming no registered resolver raises:rf.error/resource-scope-not-registered. A malformed:reply-toraises:rf.error/reply-invalid-targetor:rf.error/reply-non-data-target. Building the request raises:rf.error/resource-reserved-request-key,:rf.error/resource-unknown-transportor:rf.error/http-artefact-missing, as for a resource load. - A superseded reply (after a re-execute under the same instance, or an
:rf.mutation/clear) never overwrites the newer state; work id and generation suppress it. - See Fire the write, watch the instance.
- Example:
[:rf.mutation/clear {…}]¶
- Kind: event
- Payload:
{:instance …}to clear one instance, or{:mutation …}to clear every instance of a mutation id - Description: Resets mutation runtime state. It clears the addressed rows, aborts their in-flight work where possible and drops their work-ledger rows; the
:rf.mutation/clearedtrace names the aborted work. A pending optimistic apply is rolled back first: each touched entry is restored to its snapshot, marked stale and refetched if it has an owner, and:rf.mutation/optimistic-rolled-backis traced. A payload with neither key clears nothing. To remove the registration instead, call(rf/clear :mutation mutation-id). - Example:
Mutation subscriptions (passive)¶
A mutation subscription reads one instance's row, keyed by instance id, and never runs a write.
[:rf/mutation {:instance :form/save-1}] ;; {:status :result :error :affected-keys
;; :pending? :success? :error? :settled? :optimistic?}
[:rf.mutation/status {:instance :form/save-1}]
[:rf.mutation/pending? {:instance :form/save-1}]
[:rf.mutation/result {:instance :form/save-1}]
[:rf.mutation/error {:instance :form/save-1}]
Each focused :rf.mutation/* subscription returns the :rf/mutation view-model key of the same name.
;; a form reads its own submission's state, keyed by instance
@(rf/subscribe [:rf/mutation {:instance :form/save-1}])
;; => {:status :idle …} …then {:pending? true …} …then {:success? true …}
:statusis:idle,:pending,:successor:error. An instance reads as:idleuntil its first:rf.mutation/execute, and:settled?is true at:successor:error.:resultis the decoded reply (the reply map's:value);:erroris the managed-HTTP failure map. An accepted transport abort records:status :errorwith:kind :rf.http/abortedon the instance, while its continuation receives:status :cancelled. Clearing or superseding an instance suppresses its old continuation instead.:optimistic?(derived) is true while an optimistic apply is showing: applied but not yet settled.:affected-keysholds the scoped keys the settle touched.- There is no
:refresh-errorfor mutations, because a write has no last-known-good value to keep.
Revalidation is a frame property¶
A frame can refetch its stale data when the window regains focus or the network reconnects; Owners, causes, refetch rules covers when to turn it on. Declare which signals it listens for with the :revalidate-on frame-config key, the same way URL ownership is declared:
(rf/make-frame {:id :app :url-bound? true :revalidate-on #{:focus :reconnect}})
[rf/frame-root {:id app-frame :url-bound? true :revalidate-on #{:focus :reconnect}}
[root-view]]
:revalidate-onis optional and takes a set drawn from#{:focus :reconnect}.:focuslistens forfocusonwindowand forvisibilitychangeto visible ondocument(its only valid target), and dispatches[:rf.resource/window-focused]. One setting covers both.:reconnectlistens foronlineonwindowand dispatches[:rf.resource/network-reconnected].
- An absent key, or
#{}, installs nothing. - On each signal the runtime refetches the frame's stale entries that have an active owner. A stale entry with no active owner is left alone, since revalidation keeps nothing alive. Subscriptions never trigger this; only the two events do.
- The frame lifecycle manages the listeners. Creating the frame installs the declared set once the frame is live; re-registering it detaches whatever it had and attaches exactly the declared set, so listeners never stack and dropping the key removes them; destroying it removes them. There is no
install-revalidation-listeners!/remove-revalidation-listeners!. - The listeners exist only in CLJS. On the JVM and under SSR the key installs nothing, and nothing throws. Declaring
:revalidate-onwithoutday8/re-frame2-resourceson the classpath raises:rf.error/resources-artefact-missingwhen the frame is registered.
Polling¶
:poll-interval-ms refetches a resource every N milliseconds while its entry has at least one active owner and the document is visible, with no fetch call in any view. A route, machine or app-minted event owner keeps the poll running, and it stops as soon as the last owner releases.
(rf/reg-resource :notifications/unread-count
{:scope {:from-db :app/session}
:params-schema [:map]
:poll-interval-ms 15000 ;; refresh every 15s while owned and the tab is visible
:tags (fn [_ _] #{[:notifications]})}
(fn [_ _ctx] {:request {:method :get :url "/notifications/unread"} :decode :json}))
- A positive integer turns polling on; absent or non-positive means no polling.
- Each tick refetches on the interval, whether or not the entry is stale.
:stale-after-msstill governs focus and route-entry refetches. Structural sharing keeps views from re-rendering when an unchanged response comes back. - A poll refetch has cause
:polland no owner: it keeps nothing alive and does not extend GC. Generation and stale-reply suppression apply as for any refetch. - Ticks pause while the tab is hidden and resume when it returns; on a frame declaring
:revalidate-on #{:focus}the return also triggers the focus refetch. There is no option to keep polling while hidden. - A tick that finds a refetch already in flight is skipped, so a slow endpoint never gets overlapping requests, and focus and poll never fetch twice.
- A failed poll keeps the prior
:dataand records:refresh-error, and the next tick still fires.
An entry with no owner never polls. When no route or machine owns it, ensure it from an event with an owner named for that event (e.g. [:dashboard/opened …]), and dispatch the matching [:rf.resource/release-owner {…}] when the screen closes. See Owners, causes, refetch rules in the guide.
Infinite resources¶
An infinite resource is a load-more feed: the user sees page 1, then pages 1 and 2, then 1 to 3, rendered as one growing list. Register it with :infinite true and a pure :next-page-param, which derives the next page's param from the last page's data. Numbered or cursor pagination (:keep-previous?, one entry per page) is the other approach; choose per feed. Paginate a feed walks through both.
(rf/reg-resource :feed/timeline
{:doc "Infinite home timeline (load-more)."
:infinite true
:params-schema [:map [:filter :keyword]] ;; the feed's identity (filter/sort), not the page cursor
:scope {:from-db :app/session}
:sensitive [[:data :items :author-email]] ;; written against one page: matches the field in every item of every page
:next-page-param (fn [last-page _all-pages] ;; required; nil means no more pages
(get-in last-page [:page-info :next-cursor]))
:page->items :items ;; required when a page is not a vector
:tags (fn [{:keys [filter]} _] #{[:feed filter]})}
;; request fn (third argument); the reserved ctx carries the page param
(fn [{:keys [filter]} {:rf.resource/keys [page-param]}]
{:request {:method :get :url "/api/timeline"
:params (cond-> {:filter filter :limit 20} page-param (assoc :cursor page-param))}
:decode :app/timeline-page})) ;; validates one page
:infinite truemakes:next-page-paramrequired; omitting it raises:rf.error/infinite-missing-next-page-param. It is a pure(fn [last-page all-pages] → next-param-or-nil), andnilis the one way to say there are no more pages, exposed as the derived:has-next-page?.- A feed is one scoped entry. Its pages accumulate as an ordered vector inside that one
:rf.runtime/resourcesentry, not as one entry per page and not inapp-db, so the feed has one owner set, one freshness clock, one GC clock, one SSR-restore unit and one Xray row. The page param is internal sequencing state and not part of the cache key; changing the identity params gives a different feed. - Validate pages with the request's
:decode: a Malli schema there validates one page at a time, on page 0, each load-more and every refetch leg. Classify per-page fields with:sensitive/:large: a path[:data :field]matches[:data <page-index> :field]on every page, so the field is redacted on all of them.:data-schemadoes not apply to the accumulated vector. - Other infinite-only keys:
:prev-page-param—(fn [first-page all-pages] → param-or-nil), the mirror of:next-page-param, which feeds:rf.resource/has-prev-page?. There is no load-previous event; pages are only appended.:initial-page-param— the first page's param. Defaultnil.:page->items— a keyword or(fn [page] → items)that extracts a page's items; a vector page is its own items. A non-vector page with no:page->itemsmakes:rf.resource/items,:rf.resource/infinite-stateand anensure's:reply-toraise:rf.error/infinite-missing-page-accessor.:refetch— which pages a refetch refreshes. By default only page 0 is refetched and replaced in place; the other loaded pages stay as they are.{:refetch-all-pages? true}refreshes every loaded page in order, and{:refetch-window n}refreshes the firstn, clamped to at least one and at most the loaded page count. If both keys are present,:refetch-all-pages? truewins. Each page uses its saved page param; pages are replaced in place and the feed never shrinks.- A
:prev-page-paramthat is not a fn, a:page->itemsthat is neither a keyword nor a fn, or a:refetchthat is not a map, has a non-boolean:refetch-all-pages?or a non-integer:refetch-windowraises:rf.error/resource-bad-spec.
A view reads the merged list and dispatches [:rf.resource/load-more {…}] for the next page:
[:rf.resource/items {:resource :feed/timeline :scope … :params …}] ;; merged flat list, the main read
[:rf.resource/pages {…}] ;; raw page boundaries
[:rf.resource/has-next-page? {…}] [:rf.resource/fetching-next? {…}]
[:rf.resource/has-prev-page? {…}] ;; mirror of has-next-page? (there is no prepend event)
[:rf.resource/page-count {…}] [:rf.resource/page-error {…}]
[:rf.resource/infinite-state {…}] ;; combined view-model (the feed analogue of :rf/resource)
:rf.resource/items, :rf.resource/pages and :rf.resource/infinite-state are memoised framework subscriptions. :rf.resource/ensure, and a route entry, load page 0 only. A mutation that changes an item inside a feed invalidates the whole feed; patching one item in place inside a feed's pages is not supported.
:rf.resource/infinite-state is the feed's view-model. It keeps :status, :loading?, :stale?, :error and :refresh-error from the :rf/resource view-model, has no :data or :keep-previous? keys, and adds or changes these:
{:items <merged-items> ;; every loaded page's items, in page order
:pages <page-vector> ;; the raw pages, for page boundaries
:page-count <int> ;; the number of pages loaded
:has-next-page? <bool> ;; :next-page-param returned non-nil for the last page
:has-prev-page? <bool> ;; :prev-page-param returned non-nil for the first page
:fetching-next? <bool> ;; a load-more in flight
:fetching? <bool> ;; a whole-feed refresh in flight; false during a load-more
:has-data? <bool> ;; at least one page loaded
:page-error <failure-map-or-nil>} ;; a later page failed; the loaded pages are kept
During a load-more the feed's :status is :fetching, and the :rf/resource view-model and :rf.resource/fetching? read :fetching? true, as for a whole-feed refresh. :rf.resource/infinite-state tells the two apart: it reports a load-more as :fetching-next? true and keeps its own :fetching? for a whole-feed refresh, so it reads false while a load-more is in flight.
A feed has three error channels:
:error— page 0 failed with no pages loaded.:refresh-error— a refetch's page 0 failed; the loaded pages are kept.:page-error— a later page (a load-more or a multi-page refetch) failed; the pages are kept, and the next successful page clears it.
The :page-error and :refresh-error fields can coexist after separate failures. A successful page append or replacement clears both.
A feed's :tags fn receives the whole page vector as data. For a resource that is not :infinite, or a feed with no pages yet, the feed subscriptions read [], 0, false or nil (:rf.resource/page-error).
Cache home¶
Resource and mutation runtime state lives in the runtime-db partition (:rf.db/runtime), not in app-db:
- the resource cache, only at
:rf.runtime/resources - the frame work ledger, at
:rf.runtime/work-ledger - mutation instance rows, at
:rf.runtime/mutations
All three are reserved runtime-db keys: framework-owned, isolated per frame and allocated lazily. App code reads them through the subscriptions and the functions below and never edits them by hand.
Cache entries (durable facts) and work-ledger attempts (in-flight records) are kept separately. Host handles (AbortControllers, timers, promises) live in side tables and are never serialized. Cancellation is best-effort; stale-reply suppression by work id and generation always applies. See The cache you don't own in the guide.
Errors¶
Errors and warnings gives each id's cause and fix, and Errors says how to read a thrown id against a reported one.
- At registration:
:rf.error/resources-artefact-missing— a resource function, or a frame's:revalidate-on, without the resources artefact.:rf.error/resource-bad-spec— a malformedreg-resourcespec.:rf.error/resource-missing-scope-policy—:scopeabsent, or not one of the two policy shapes.:rf.error/infinite-missing-next-page-param—:infinite truewithout:next-page-param.:rf.error/mutation-bad-spec— a malformedreg-mutationspec.:rf.error/mutation-optimistic-before-request— an optimistic plan with:invalidate-timing :before-request.:rf.error/invalid-resource-scope-spec— a malformedreg-resource-scope.:rf.error/resource-scope-source-reserved— a[:runtime …]input onreg-resource-scope.
- When a resource is ensured, read or subscribed:
:rf.error/resource-not-registered— the payload names an unregistered:resource.:rf.error/resource-invalid-params— params that fail:params-schema.:rf.error/resource-non-edn-params— params or a scope that are not portable EDN.:rf.error/resource-invalid-scope— a misspelt:rf.scope/*keyword,[:rf.scope/global], or a{:from-db …}map where a concrete scope is required.:rf.error/resource-scope-not-registered— a{:from-db …}reference naming no registered resolver.:rf.error/resource-scope-unresolved-reference— an event's{:from-db …}scope resolves tonil.:rf.error/resource-sub-unresolved-scope— a subscription's, orresource-state's,{:from-db …}scope resolves tonil.:rf.error/resource-invalidate-scope-required— a scopedinvalidate-tagswithout:scope.:rf.error/resource-cross-scope-cause-required— a cross-scopeinvalidate-tagswithout:cause.:rf.error/resource-cross-scope-scope-conflict— a cross-scopeinvalidate-tagsthat also names:scope.:rf.error/reply-invalid-target— a:reply-tothat is not a non-empty vector with a keyword head.:rf.error/reply-non-data-target— a:reply-tocarrying a fn or other host object.:rf.error/resource-reserved-request-key— a request fn that returns:request-id,:on-successor:on-failure.:rf.error/resource-unknown-transport— a:transportother than:rf.http/managed.:rf.error/http-artefact-missing— a load or write withoutre-frame.http.managed.:rf.error/infinite-missing-page-accessor— a feed whose pages are not vectors and that has no:page->items, when:rf.resource/items,:rf.resource/infinite-stateor anensure's:reply-toneeds its items.:rf.error/resource-route-plan— a route's resource plan failed, for example on a scope it cannot resolve; read on:rf.route/error.:rf.error/resource-route-blocking— a blocking route resource's first load failed; read on:rf.route/error.:rf.error/resource-ssr-blocking-timeout— under SSR, a blocking resource did not settle within the render deadline, so it settles as a first-load failure.:rf.error/no-frame-context—resource-stateormutation-statewithout:frame.
- When a mutation runs or settles, beside the params, scope,
:reply-toand request-building ids above::rf.error/mutation-not-registered— an unregistered:mutation.:rf.error/mutation-invalid-params— params that fail the mutation's:params-schema.:rf.error/mutation-non-serializable-instance-id— an:instancethat is not portable EDN.:rf.error/mutation-invalid-target— a malformed:optimistictarget, before the request is sent, or a success-arm target whose params are not portable EDN, when the write settles.:rf.error/mutation-invalid-invalidation—:invalidatesreturned neither a tag set nor descriptors, when the write settles.
- Warnings, in development builds:
:rf.warning/mutation-scope-mismatch— the invalidated tags matched nothing in the resolved scope but match entries in another.:rf.warning/mutation-target-skipped— a success-arm target that is not a map, has a non-keyword:resourceor names an unregistered resource, skipped.:rf.warning/optimistic-tags-descriptor-skipped— a malformed:optimistic-tagsdescriptor, dropped.:rf.warning/optimistic-force-clobber—:on-conflict :forcerestored a snapshot over a newer write.:rf.warning/resource-load-more-owner-ignored— aload-morethat carries an:owner.
Framework integration¶
Not for application code — used by adapters, tools and the test harness.
The reads here return one-shot, non-reactive snapshots for Xray, unit tests and SSR serialization. They do not re-render on change; views read the same state through the subscriptions. There are no dedicated accessors (resource-meta, mutation-meta, resource-ids, mutation-ids, scope-resolver-meta, scope-resolver-ids, or a bundled resources / mutations read): registrations are read with the generic registrar functions, live state with resource-state / mutation-state or at the reserved runtime-db paths.
Reading registrations¶
rf/registrations lists the ids registered under a kind, and rf/handler-meta plus the kind's inner key (:rf/resource, :rf/mutation, :rf/resource-scope) returns one registered spec, or nil. These reads take no frame and need no artefact. Source coords are on the enclosing handler-meta map, not in the projected spec.
(keys (rf/registrations {:source :store :kind :resource})) ;; => (:article/by-slug :feed/timeline)
(keys (rf/registrations {:source :store :kind :mutation})) ;; => (:article/save)
(keys (rf/registrations {:source :store :kind :resource-scope})) ;; => (:realworld/session)
(:rf/resource (rf/handler-meta {:source :store :kind :resource
:id :article/by-slug}))
;; => {:scope :rf.scope/global :params-schema [...] :request #fn ... :doc "…"}
(:rf/mutation (rf/handler-meta {:source :store :kind :mutation
:id :article/save}))
;; => {:request #fn ... :params-schema :app/article :invalidates #fn ... :scope :rf.scope/global …}
(:rf/resource-scope (rf/handler-meta {:source :store :kind :resource-scope
:id :realworld/session}))
;; => {:inputs {:username [:db [:auth :user :username]]} :resolve #fn :whole-db? false :doc nil}
:rf/resourcereturns the metadata map as registered, plus:request(the third-argument fn): every key the registration supplied,:infiniteand its keys,:timeout-msand the classification keys included, and no key it left out.:gc-after-msis always present, normalized (absent →300000).:rf/mutationreturns the metadata map as registered, plus:request; an omitted key is absent, not defaulted.:rf/resource-scopereturns the resolver's canonical spec::inputs,:resolve,:whole-db?and:doc.:whole-db?is derived, not authored: true when some declared input targets the root path ({:inputs {:db [:db []]}}).
resource-state¶
- Kind: function
- Signature:
- Description: Returns one resource instance's durable runtime entry at an explicit frame, or
nilwhen no entry exists. The scoped key resolves as a subscription's does, so a{:from-db <id>}scope resolves against the frame'sapp-db.- An absent or
nil:frameraises:rf.error/no-frame-context. There is no fallback to:rf/default; returningnilwould be indistinguishable from an absent entry. - An explicit but unknown or destroyed
:framereads asnil. With a{:from-db …}scope, the scope resolves against that frame'sapp-db, which reads asnil, so a resolver that returnsnilfor it raises:rf.error/resource-sub-unresolved-scope. :frameis a frame id or a live frame value.- An invalid key raises the same errors as a subscription (see Resource subscriptions).
- An absent or
- Example:
mutation-state¶
- Kind: function
- Signature:
- Description: Returns one mutation instance's durable runtime row (
{:status :result :error …}) at an explicit frame, ornil.- An absent or
nil:frameraises:rf.error/no-frame-context, as forresource-state. - An explicit but unknown or destroyed
:framereads asnil.
- An absent or
- Example:
Enumerating the whole live table¶
The registry, the whole live table and one entry are three different reads. The registry says what is registered and takes no frame. The live tables are runtime-db state, read at an explicit frame from the reserved paths in Cache home. resource-state and mutation-state narrow to one target.
;; 1. REGISTRY — every registered id, no frame.
(keys (rf/registrations {:source :store :kind :resource})) ;; => (:article/by-slug :feed/timeline)
(keys (rf/registrations {:source :store :kind :mutation})) ;; => (:article/save)
;; 2. WHOLE LIVE TABLE — the reserved runtime-db path off the frame-state projection.
(get-in (rf/frame-state-value :app/main) [:rf.db/runtime :rf.runtime/resources :entries])
;; => {<key-id> {:resource/id :article/by-slug
;; :resource/key [:rf.scope/global :article/by-slug {:slug "welcome"}]
;; :data … :error … :generation … :current-work …}
;; …}
(get-in (rf/frame-state-value :app/main) [:rf.db/runtime :rf.runtime/mutations])
;; => {<key-id> {:mutation/id :article/save :instance/id :form/save-1
;; :status … :result … :error … :generation …}
;; …}
;; 3. ONE ENTRY / ONE INSTANCE — the narrowed reads documented above.
(rf/resource-state {:resource :article/by-slug :scope :rf.scope/global
:params {:slug "welcome"} :frame :app/main})
(rf/mutation-state {:instance :form/save-1 :frame :app/main})
Read each table by its own keys. :entries is keyed by each entry's key-id, a string encoding of the key in canonical EDN (CEDN-1) that keeps a list and a vector of the same values distinct; the readable [scope resource-id params] tuple is on the row as :resource/key, and re-keying the table by it can collapse distinct entries. :rf.runtime/mutations is keyed by the CEDN-1 byte key-id of each mutation instance id (the row carries its own :instance/id), never by mutation id, so concurrent submissions of the same mutation stay distinct; re-keying by :mutation/id collapses them.
Both subtrees are allocated lazily: :rf.runtime/resources is absent until the first resource write, and :rf.runtime/mutations is absent until the first :rf.mutation/execute, so either read can return nil at a live frame. rf/frame-state-value also returns nil for an unknown or destroyed frame, so a nil here means "not allocated" only at a frame you know is live; otherwise check (rf/frame-state-value :app/main) itself first.
Xray¶
Xray's Resources panel shows the same shapes, plus the route/resource graph, the work-ledger table and the scope audit, a standing list of every :rf.scope/global resource. Its projections prefer summaries to raw values, and params and scopes get the same privacy and size elision as data. Xray has no read-only resource accessors (no list-resources / get-resource-state family); an out-of-process reader uses re-frame2-pair against the framework's registry, runtime-db and trace surfaces.
Internal events and trace ops¶
These ids appear in traces and in Xray. Application code must not dispatch them.
:rf.resource.internal/succeeded,…/failed,…/page-succeeded,…/page-failed,…/stale-fired,…/gc-fired,…/poll-fired,…/stale-suppressedand…/refetch-pageare the resource runtime's replies and timer ticks. The replies carry:work/id,:resource/key,:scope,:generationand:rf.frame/id, and their handlers check frame, work id and generation before writing, which is where stale replies are suppressed. The timer ticks carry the:resource/key(a poll tick adds:hidden?) and re-check the entry when they fire.…/stale-firedis the stale-timer re-check tick. It arms the stale transition and does not fetch.…/poll-firedis the poll-timer re-check tick. It refetches an actively owned entry.…/page-succeededand…/page-failedare the infinite-feed page replies;…/refetch-pageis one leg of a multi-page refetch.- An abort arrives on
…/failedand settles as a cancellation, not an error (the:rf.http/abortedbranch). There is no separate aborted reply.
:rf.resource.internal/adopt-ownerattaches an owner to an existing entry without fetching, and…/release-owner-identitiesreleases an owner from a subset of the entries it holds. The route planner uses both to hand owners over.:rf.resource/cache-hitis the trace op for a freshensureserved from cache with no fetch. For a blocking route resource it also releases the blocking slot at once, with no:fetchingtransition.:rf.resource/stale-firedis the trace op for a stale-timer tick. Neither is a:statusvalue.:rf.mutation.internal/succeededand…/failedare the mutation replies. The:rf.mutation/*trace family isstarted,succeeded,failed,cleared,repliedandstale-suppressed, plus the optimistic rowsoptimistic-applied,optimistic-reconciledandoptimistic-rolled-back. Both mutation families carry the instance id.
See also¶
- Glossary — the resources and server-state vocabulary.
- Testing resources — reads, scope resolvers and mutations under test.
- Errors and warnings — the resources error and warning ids, grouped by when they fire, with each one's cause and fix.
- Migration: re-frame-query → resources — moving off
shipclojure/re-frame-queryor a hand-rolled Pattern-RemoteData cache. - Managed HTTP — the
:rf.http/managedtransport and the:rf.http/*failure taxonomy. - re-frame.routing — the
:resourcesroute option and its entry keys. - re-frame.ssr — the hydration install path.