Declarative data fetching and caching for re-frame, inspired by TanStack Query and RTK Query.
:on-success / :on-failure plumbingcache-time-ms via per-query timers (same model as TanStack Query):skip? true until a condition is met (e.g., dependent queries):on-start, :on-success, :on-failure for optimistic updates and rollback;; deps.edn
{:deps {com.shipclojure/re-frame-query {:mvn/version "0.9.0"}}}
;; Leiningen/Boot
[com.shipclojure/re-frame-query "0.9.0"]
(ns my-app.queries
(:require [re-frame.query :as rfq]))
(rfq/init!
{:default-effect-fn
(fn [request on-success on-failure]
{:http-xhrio (assoc request :on-success on-success :on-failure on-failure)})
:queries
{:todos/list
{:query-fn (fn [{:keys [user-id]}]
{:method :get
:url (str "/api/users/" user-id "/todos")})
:stale-time-ms 30000
:cache-time-ms (* 5 60 1000)
:tags (fn [{:keys [user-id]}]
[[:todos :user user-id]])}}
:mutations
{:todos/add
{:mutation-fn (fn [{:keys [user-id title]}]
{:method :post
:url (str "/api/users/" user-id "/todos")
:body {:title title}})
:invalidates (fn [{:keys [user-id]}]
[[:todos :user user-id]])}}})
No :on-success / :on-failure wiring needed — the library auto-injects callbacks via your default-effect-fn.
Incremental API — You can also register queries and mutations one at a time with
rfq/reg-query,rfq/reg-mutation, andrfq/set-default-effect-fn!.
There are two ways to wire a query into a view — pick whichever fits your app. Both end up with the same data shape in app-db.
Trigger the fetch from your router's enter/leave hooks; render with the passive ::rfq/query-state sub. This matches re-frame's pure-subscription philosophy — fetching is an explicit event, the view just reads.
;; 1. In your router (e.g. reitit :controllers), wire enter/leave to events
;; {:name :todos
;; :controllers [{:start #(rf/dispatch [:routes/todos-entered])
;; :stop #(rf/dispatch [:routes/todos-left])}]}
(rf/reg-event-fx :routes/todos-entered
(fn [_ _]
{:fx [[:dispatch [::rfq/ensure-query :todos/list {:user-id 42}]]
[:dispatch [::rfq/mark-active :todos/list {:user-id 42}]]]}))
(rf/reg-event-fx :routes/todos-left
(fn [_ _]
{:fx [[:dispatch [::rfq/mark-inactive :todos/list {:user-id 42}]]]}))
;; 2. Views subscribe via the passive sub — pure read, no side effects
(defn todos-view []
(let [{:keys [status data error fetching?]}
@(rf/subscribe [::rfq/query-state :todos/list {:user-id 42}])]
(case status
:loading [:div "Loading..."]
:error [:div "Error: " (pr-str error)]
:success [:div
[:ul (for [todo data]
^{:key (:id todo)} [:li (:title todo)])]
(when fetching? [:span "Refreshing..."])]
[:div "Idle"])))
mark-active / mark-inactive drive the same lifecycle (polling, GC, refetch-on-invalidation) that ::rfq/query manages automatically — you just own the trigger points. This is also where you'd install route-scoped global interceptors for analytics or per-page behaviour.
use-query-style)If you'd rather have subscribing trigger the fetch like React Query's useQuery, use ::rfq/query. It's built with reg-sub-raw and uses Reagent's Reaction lifecycle: on subscribe it fetches if absent/stale, marks active, and starts polling; on dispose it marks inactive and starts the GC timer. Multiple components subscribing to the same [k params] share a single cache entry.
(defn todos-view []
(let [{:keys [status data error fetching?]}
@(rf/subscribe [::rfq/query :todos/list {:user-id 42}])]
...))
Trade-off:
::rfq/querydispatches events as a side effect of subscribing, which technically violates re-frame's pure-subscription guidance. It's a deliberate ergonomics trade-off mirroringuseQuery— convenient for simple views, but Option A is easier to test and reason about for non-trivial flows.
(rf/dispatch [::rfq/execute-mutation :todos/add {:user-id 42 :title "Ship it"}])
On success, mutations automatically invalidate matching tags — all active queries with those tags are refetched.
(rf/dispatch [::rfq/invalidate-tags [[:todos :user 42]]])
| Guide | Description |
|---|---|
| API Reference | Events, subscriptions, config keys, query state shape |
| Status Tracking | How :status and :fetching? distinguish loading states |
| Garbage Collection | Per-query timer-based cache eviction |
| Polling | Query-level, per-subscription, and multi-subscriber polling |
| Conditional Fetching | :skip? for dependent queries |
| Prefetching | Pre-populate cache on hover or route transition |
| Placeholder Data | Seed the cache from existing client data on route enter, with background refetch |
| Where Data Lives | app-db layout, inspectability, serialization |
| Effect Overrides | Per-query transport, custom callbacks |
| Lifecycle Hooks | Mutation hooks, optimistic updates, request cancellation, query observability via interceptors |
| Infinite Queries | Cursor-based pagination, sequential re-fetch, sliding window |
[::rfq/query k params] fetches data (if absent/stale) and marks the query activequery-fn returns a request map; the library wraps it with callbacks via effect-fnsetTimeout based on cache-time-msTwo full example apps with 8 tabs each (Basic CRUD, Polling, Dependent Queries, Prefetching, Mutation Lifecycle, WebSocket, Optimistic Updates, Infinite Scroll):
| App | Framework | Port | Directory |
|---|---|---|---|
| Reagent | Reagent + re-frame | 8710 | examples/reagent-app/ |
| UIx | UIx v2 + re-frame | 8720 | examples/uix-app/ |
Both use MSW (Mock Service Worker) to intercept fetch requests with an in-memory API so you can see the queries in the network tab.
cd examples/reagent-app # or examples/uix-app
pnpm install && pnpm run mocks && pnpm exec shadow-cljs watch demo
# Run unit tests
bb test:unit
# Run e2e tests (both example apps)
bb test:e2e
# Format code
bb fmt
# Check formatting
bb fmt:check
MIT
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |