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.2.0"}}}
;; Leiningen/Boot
[com.shipclojure/re-frame-query "0.2.0"](ns my-app.queries
(:require [re-frame.query :as rfq]))
(rfq/init!
{:default-effect-fn
(fn [request on-success on-failure]
{:http (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!.
Think of (rf/subscribe [::rfq/query k params]) like a use-query hook —
subscribing is all you need. It triggers the fetch, caches the result, and
keeps it fresh.
(defn todos-view []
(let [{:keys [status data error fetching?]}
@(rf/subscribe [::rfq/query :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"])))Unlike a typical re-frame subscription that just reads from app-db, ::rfq/query is built with reg-sub-raw — it uses Reagent's Reaction lifecycle to manage the query automatically:
- On subscribe: fetches data if absent/stale, marks query active, starts polling if configured
- While subscribed: returns query state reactively; multiple components share a single cache entry
- On dispose: marks query inactive, starts GC timer, stops polling
A note on re-frame philosophy: re-frame recommends that subscriptions be pure reads. Our
::rfq/querydispatches events as a side effect of subscribing — a deliberate trade-off mirroring React Query'suseQuery. If you prefer explicit control, dispatch::rfq/ensure-queryand::rfq/mark-activeyourself and use the passive derived subscriptions (::rfq/query-data, etc.) instead.
(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 |
| Where Data Lives | app-db layout, inspectability, serialization |
| Effect Overrides | Per-query transport, custom callbacks |
| Mutation Hooks | Lifecycle hooks, optimistic updates, request cancellation |
| 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.
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