Declarative data fetching and caching for re-frame, inspired by TanStack Query and RTK Query.
- Declarative queries & mutations — describe what to fetch, the library handles when and how
- Automatic callback wiring — no manual
:on-success/:on-failureplumbing - Tag-based cache invalidation with automatic refetching of active queries
- Per-query garbage collection — inactive queries are cleaned up after
cache-time-msvia per-query timers (same model as TanStack Query) - Polling — automatic refetch intervals with per-subscriber or per-query config; multiple subscribers use the lowest non-zero interval
- Conditional fetching — skip queries with
:skip? trueuntil a condition is met (e.g., dependent queries) - Prefetching — pre-populate the cache before a component subscribes (on hover, route transition, etc.)
- Smart status tracking — distinguishes initial loading from background refetching
- Transport-agnostic — works with any re-frame effect (HTTP, GraphQL, WebSocket, etc.)
- All state in re-frame DB — predictable, inspectable, time-travel debuggable
- Infinite queries — cursor-based pagination with automatic sequential re-fetch on invalidation, sliding window support
- Mutation lifecycle hooks —
:on-start,:on-success,:on-failurefor optimistic updates and rollback - Subscription-driven — subscribing is all you need; fetching, caching, and cleanup are automatic
;; deps.edn
{:deps {com.shipclojure/re-frame-query {:mvn/version "0.10.1"}}}
;; Leiningen/Boot
[com.shipclojure/re-frame-query "0.10.1"](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.
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 |
- Subscribing to
[::rfq/query k params]fetches data (if absent/stale) and marks the query active query-fnreturns a request map; the library wraps it with callbacks viaeffect-fn- On success, the cache updates with data, timestamps, and tags; on failure, the error is stored
- Mutations invalidate matching tags — active queries with those tags are automatically refetched
- Unsubscribing marks the query inactive and starts a per-query GC timer
- GC fires per-query via
setTimeoutbased oncache-time-ms
Two 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:checkMIT