Skip to content

Coming from TanStack Query

Keep the idea of a keyed server cache with freshness, deduplication and invalidation. In re-frame2, registration describes the request, an event or route starts it, and a subscription reads the cached result. This page maps that workflow and the defaults that matter during migration.

The mapping

TanStack Query re-frame2 resources
queryKey [scope resource-id canonical-params]; declare scope on the resource
queryFn The third argument of reg-resource, returning managed-HTTP request data
useQuery result [:rf/resource {:resource … :params …}], read with subscribe
Start or ensure a query Route :resources or :rf.resource/ensure
refetch() :rf.resource/refetch
enabled: false Do not ensure; use route :when for conditional loads
select An ordinary subscription deriving from :rf.resource/data
staleTime Resource :stale-after-ms
gcTime Resource :gc-after-ms, with different timer semantics below
refetchInterval Resource :poll-interval-ms, while owned and visible
refetchOnWindowFocus / refetchOnReconnect Frame :revalidate-on #{:focus :reconnect}
placeholderData: keepPreviousData :keep-previous? on an ensure or route resource
useMutation reg-mutation, :rf.mutation/execute, then :rf/mutation by instance id
invalidateQueries after a write Mutation :invalidates, matching tags within a scope
setQueryData after a write Mutation :populates or :patches
onSuccess / onError workflow An execute's :reply-to event
useInfiniteQuery / fetchNextPage :infinite true / :rf.resource/load-more
data.pages :rf.resource/pages; :rf.resource/items merges the item lists

The API reference gives the exact payloads, return values and errors. isFetching needs care: resource :loading? means a first load, while :fetching? means a refresh over existing data. Use (or (:loading? state) (:fetching? state)) for either kind of in-flight read. Infinite state separates :fetching-next? from whole-feed :fetching?.

Defaults to choose explicitly

TanStack's documented defaults include immediately stale cached queries, background revalidation on focus and reconnect, and automatic query retries. Resources chooses these separately:

Policy Resources default Migration choice
Time-based staleness Never stale by time alone Set :stale-after-ms, including 0 for immediately stale
Focus/reconnect Off Declare the frame's :revalidate-on set
Retries Off for reads and writes Add :retry to the managed-HTTP args returned by the request function
Unowned cache retention GC checks every 300000 ms Set :gc-after-ms, or :never to keep unowned entries

A GC check is armed when a load settles and repeats while the entry is owned or loading. Releasing the final owner does not start a new five-minute retention window; collection can happen at the next pending check.

(rf/reg-resource :article/by-slug
  {:params-schema [:map [:slug :string]]
   :scope :rf.scope/global
   :stale-after-ms 60000}
  (fn [{:keys [slug]} _ctx]
    {:request {:method :get :url (str "/api/articles/" slug)}
     :decode :json}))

(rf/make-frame {:id :app :revalidate-on #{:focus :reconnect}})

Load re-frame.resources and re-frame.http.managed before using these forms, as the model shows.

Give each read a cause and an owner

Subscribing never starts work. If a page remains :idle, check its route's :resources declaration or the event that should ensure it. The read example connects registration, route and view. For preloading, an ownerless ensure warms one entry; route prefetch warms the destination's resource plan.

An owner has the lifetime role that an active query observer usually has. A route owns its entries until you leave; a panel can attach and release an application owner. Mounted subscriptions themselves do not keep an entry alive. Invalidation immediately refetches owned matches and only marks unowned matches stale.

Put viewer identity in the scope

Every resource declares either :rf.scope/global or a named {:from-db …} resolver. Omitting that declaration is an error. A resolver returning nil also raises when a read needs a scope; it never silently chooses global data.

This does not infer authentication for you. If a response changes with the viewer, tenant or permissions, the scope must include that distinction. A public article with a viewer-relative favorited flag needs a viewer scope too. The scope tutorial demonstrates that case, including session restore and logout cleanup.

Declare cache consequences on the write

:invalidates runs for every call of its mutation, so each button needs only to execute the write. Tags connect the mutation to all affected reads. Return scoped descriptors when a write affects several scopes; an unmatched scope invalidates nothing. Direct :rf.resource/invalidate-tags remains useful for changes arriving from a websocket or another server signal.

Use :populates when the response already contains a complete cached value, and :reply-to when completion should navigate or show a message. The mutation recipe develops those cases.

Choose how optimistic changes settle

TanStack documents optimistic UI and cache-update patterns. Resources' :optimistic and :optimistic-tags update the cache before the request and record a snapshot for rollback. If another write changes an entry before rollback, the default :on-conflict :invalidate marks it stale instead of restoring an obsolete snapshot. :force restores anyway.

Keep controls disabled while pending when requests must arrive at the server in order. Suppressing stale replies protects client state; it cannot undo a write already applied by the server.

Scope the cache lifetime to a frame

Each frame has its own cache. SSR uses a frame per request and hydrates eligible entries into the client frame. A fresh hydrated entry can serve the next ensure without another request. Scope must agree on both sides; classified data may be withheld and loaded on the client instead.

Cross-cutting auth headers belong in a managed-HTTP interceptor. Read its frame's current token when decorating the request, so resources, mutations and direct managed requests use the same policy.

Check the boundaries of the migration

Infinite resources accumulate pages in one entry. Their default refetch replaces page zero and retains the tail. Choose :refetch {:refetch-all-pages? true} to refresh every loaded page, or :refetch {:refetch-window n} for a leading window. There is no prepend event or automatic page eviction. A failed later-page refetch uses :page-error.

Resources provides neither an Apollo/Relay-style normalized entity cache nor offline persistence or cross-tab broadcast. If those are requirements, plan their integration explicitly.

When to reach for resources at all

Use resources when cached reads, freshness and invalidation simplify the app. For a one-off login request or a result an event stores in app-db, managed HTTP is enough. RTK Query and SWR users can use the same resource model, but should compare their own retry and retention policies rather than assuming TanStack's defaults apply.