re-frame-query exposes lifecycle information differently for mutations and queries:
- Mutations support explicit per-call hooks (
:on-start,:on-success,:on-failure) passed as top-level keys of theexecute-mutationpayload map. They're scoped to a single action and commonly power optimistic updates. - Queries are observed via re-frame global interceptors on the library's lifecycle events. This matches the fact that query fetches can originate from many places (subscriptions, navigation events, polling, tag invalidation, prefetches) — a per-call hook would silently miss most of them.
Add :on-start / :on-success / :on-failure keys to the execute-mutation payload map to hook into the mutation lifecycle:
(rf/dispatch [::rfq/execute-mutation {:mutation :todos/toggle
:params {:id 5 :done true}
:on-start [:my-app/on-start-event]
:on-success [:my-app/on-success-event]
:on-failure [:my-app/on-failure-event]}])| Hook | When | Args conj'd onto each event vector |
|---|---|---|
:on-start |
Before the effect fires | params |
:on-success |
After mutation succeeds | params, response-data |
:on-failure |
After mutation fails | params, error |
Each hook takes an event vector. Hooks are optional; omitting the hook keys works exactly as before.
To dispatch several events from one hook, pass a vector of event vectors — all of them are dispatched:
(rf/dispatch [::rfq/execute-mutation {:mutation :todos/toggle
:params {:id 5 :done true}
:on-success [[:my-app/refresh-badge]
[:my-app/toast "Saved"]]}])rfq conj's its own args onto every hook event you register. This is different from how day8/http-fx and most other re-frame HTTP effects work — those dispatch (conj on-success-event response), appending only the response. rfq appends params and (for :on-success/:on-failure) the response or error.
If you pre-bind data in the hook event vector, those values sit before rfq's appended args:
;; Dispatch:
[::rfq/execute-mutation {:mutation :todos/toggle
:params {:id 5}
:on-success [:my/hook extra-1 extra-2]}]
;; Hook handler receives:
(fn [cofx [_ extra-1 extra-2 mutation-params response]] ...)
;; ^^^^^^^ ^^^^^^^ ^^^^^^^^^^^^^^^^ ^^^^^^^^
;; your pre-bound args rfq args;; No pre-bound args
(rf/reg-event-fx :my/on-success
(fn [_ [_ params response]] ...))
(rf/reg-event-fx :my/on-failure
(fn [_ [_ params error]] ...))
(rf/reg-event-fx :my/on-start
(fn [_ [_ params]] ...))
;; With pre-bound args (e.g. a user-supplied callback fn)
(rf/dispatch [::rfq/execute-mutation {:mutation :todos/add
:params {:title "x"}
:on-success [:my/on-success some-data]}])
(rf/reg-event-fx :my/on-success
(fn [_ [_ some-data params response]] ...))Hooks receive params so they can operate on the same input the mutation ran with — essential for optimistic-update snapshots keyed by the mutation input, rollback logic, and generic analytics/toast interceptors. Making params implicit (always passed) keeps hook events reusable across call sites without threading mutation inputs through the event vector manually.
Use lifecycle hooks + set-query-data to build optimistic updates in pure re-frame:
;; 1. Register hook events — these are YOUR event handlers, not library code
(rf/reg-event-fx :todos/optimistic-toggle
(fn [{:keys [db]} [_ {:keys [id done]}]]
(let [qid [:todos/list {}]
old (get-in db [:re-frame.query/queries qid :data])
new (mapv #(if (= (:id %) id) (assoc % :done done) %) old)]
{:db (assoc-in db [:snapshots qid] old) ;; save snapshot
:dispatch [::rfq/set-query-data {:query :todos/list :data new}]}))) ;; patch cache
(rf/reg-event-fx :todos/rollback
(fn [{:keys [db]} [_ _params _error]]
(let [qid [:todos/list {}]
old (get-in db [:snapshots qid])]
{:db (update db :snapshots dissoc qid)
:dispatch [::rfq/set-query-data {:query :todos/list :data old}]}))) ;; restore snapshot
;; 2. Dispatch mutation with hooks
(rf/dispatch [::rfq/execute-mutation {:mutation :todos/toggle
:params {:id 5 :done true}
:on-start [:todos/optimistic-toggle]
:on-failure [:todos/rollback]}])The checkbox toggles instantly. If the server rejects, the snapshot is restored. No library magic — just re-frame events and data.
Race condition note: If a query has active polling or an in-flight refetch, the refetch response could briefly overwrite your optimistic data before the mutation completes. In practice this race is rare and self-correcting — the mutation's
:invalidatestriggers a fresh refetch with correct server data immediately after success. If you need to guard against it, dispatch::rfq/cancel-queryalongsideset-query-data— see below.
rfq/cancel-query— a built-in, state-layer cancel. It supersedes whatever request is in flight for a query so its response is dropped the moment it lands, without touching your transport. Zero setup, works with any effect adapter.- Aborting the network request itself — actually stopping the HTTP call (or websocket, etc.) so it doesn't run to completion. Since re-frame-query is transport-agnostic, this still lives in your transport layer, not in the library. Only worth the extra plumbing if the wasted request itself is a problem (bandwidth, server load), not just its effect on
app-db.
Every fetch is stamped with a fresh :request-id when it starts. Calling rfq/cancel-query claims a new :request-id without issuing a request, so whatever response is still in flight no longer matches and is dropped on arrival — :fetching? clears immediately, and :data/:status/:error are left exactly as they are.
Dispatch it in the same on-start hook that patches the optimistic update:
(rf/reg-event-fx :todos/optimistic-toggle
(fn [{:keys [db]} [_ {:keys [id done]}]]
(let [qid [:todos/list {}]
old (get-in db [:re-frame.query/queries qid :data])
new (mapv #(if (= (:id %) id) (assoc % :done done) %) old)]
{:db (assoc-in db [:snapshots qid] old)
:dispatch-n [[::rfq/cancel-query {:query :todos/list}] ;; drop any in-flight response
[::rfq/set-query-data {:query :todos/list :data new}]]}))) ;; patch cacheOr call re-frame.query.db/cancel-query directly if you're already inside a db -> db handler and want to avoid the extra dispatch cycle. rfq/cancel-query is also useful on its own, with no cache write — e.g. abandoning a slow infinite re-fetch, or leaving a route for which a query is already in flight.
Plain rfq/cancel-query does not abort the network call — the request keeps running to completion, its response is just dropped at the state layer. If the wasted request itself is a problem (bandwidth, server load), also abort it in your transport layer. You no longer need to hand-roll an :abort-key through query-fn — rfq/request-control gives you the same :query-id re-frame-query itself uses, read straight off on-success:
;; 1. Store AbortControllers per query in your transport layer
(defonce abort-controllers (atom {}))
(rf/reg-fx :http-xhrio
(fn [{:keys [method url body on-success on-failure]}]
(let [{:keys [query-id]} (rfq/request-control on-success)
controller (js/AbortController.)
signal (.-signal controller)]
(when query-id
(when-let [old (get @abort-controllers query-id)]
(.abort old)) ;; a newer attempt supersedes the old one
(swap! abort-controllers assoc query-id controller))
(-> (js/fetch url (clj->js {:method (name method)
:headers {"Content-Type" "application/json"}
:signal signal
:body (some-> body clj->js js/JSON.stringify)}))
(.then #(when (.-ok %) ...dispatch on-success...))
(.catch #(when-not (.-aborted signal) ;; silently drop aborted requests
...dispatch on-failure...))))))
;; 2. Register an effect that aborts a request by key on demand
(rf/reg-fx :abort-request
(fn [query-id]
(when-let [controller (get @abort-controllers query-id)]
(.abort controller)
(swap! abort-controllers dissoc query-id))))
;; 3. In your on-start hook, abort the in-flight refetch's network call
;; *and* drop it at the state layer, then patch the cache
(rf/reg-event-fx :todos/optimistic-toggle
(fn [{:keys [db]} [_ {:keys [id done]}]]
(let [qid [:todos/list {}]
old (get-in db [:re-frame.query/queries qid :data])
new (mapv #(if (= (:id %) id) (assoc % :done done) %) old)]
{:db (assoc-in db [:snapshots qid] old)
:abort-request qid ;; stop the network call
:dispatch-n [[::rfq/cancel-query {:query :todos/list}] ;; clear :fetching?, drop the response
[::rfq/set-query-data {:query :todos/list :data new}]]}))) ;; patch cacheqid here is exactly (util/query-id :todos/list {}) — the same value rfq/request-control reports as :query-id in step 1 — so the key you dispatch :abort-request with always matches what the adapter has stored, with no separate :abort-key to keep in sync.
The :http-xhrio adapter's own (.abort old) in step 1 also aborts an older attempt automatically the instant a newer one for the same k/params starts firing (e.g. an overlapping refetch), so step 2/3's explicit :abort-request is only needed for cancelling before a replacement request exists — like the optimistic-update case above.
Aborting the network call alone leaves :fetching? stuck true forever, since an aborted request fires neither on-success nor on-failure — that's why step 3 dispatches ::rfq/cancel-query alongside :abort-request rather than relying on either alone.
Queries don't have per-call :on-start/:on-success/:on-failure hooks. The reason is that a single query key can be fetched from many entry points in the same session — ensure-query, refetch-query, polling ticks, tag invalidations, prefetches, or the ::rfq/query subscription — and most of those paths have no natural place to carry caller-supplied opts. Baking hooks into only some of them would be a footgun.
The map payload enforces this loudly: putting a hook key on any query event or subscription throws instead of being silently ignored, and the error points you at the interceptor lane below.
(rf/dispatch [::rfq/ensure-query {:query :books/list :params {:page 1} :on-success [:my/loaded]}])
;; => ExceptionInfo: re-frame-query: :re-frame.query/ensure-query does not accept #{:on-success}
;; — per-call lifecycle hooks exist on mutations only. To observe query lifecycles, register a
;; re-frame global interceptor over the rfq result events and parse them with
;; re-frame.query/parse-result-event (see docs/lifecycle-hooks.md).Instead, observe the library's lifecycle events with a re-frame global interceptor. The events are stable and part of the public surface:
| Event | Carries | When |
|---|---|---|
[:re-frame.query/ensure-query k params] |
k, params |
A fetch is about to start (not fired on cache hits) |
[:re-frame.query/refetch-query k params] |
k, params |
A forced refetch is starting |
[:re-frame.query/query-success k params data] |
k, params, post-:transform-response data |
Success, after :db commit |
[:re-frame.query/query-failure k params error] |
k, params, post-:transform-error error |
Failure, after :db commit |
Because these fire regardless of which entry point triggered the fetch, a single interceptor will reliably observe every lifecycle transition for the queries you care about.
Use rfq/parse-result-event to extract the event into a map, and
re-frame.interceptor/update-effect to enqueue dispatches via :fx —
keeping the interceptor a pure context -> context function:
(require '[re-frame.interceptor :as rfi])
(rf/reg-global-interceptor
(rf/->interceptor
:id :my-app/query-telemetry
:after
(fn [context]
(let [{:keys [event-id k params data error]}
(rfq/parse-result-event (get-in context [:coeffects :event]))]
(case event-id
:re-frame.query/query-success
(rfi/update-effect context :fx (fnil conj [])
[:dispatch [:analytics/query-succeeded k params]])
:re-frame.query/query-failure
(rfi/update-effect context :fx (fnil conj [])
[:dispatch [:analytics/query-failed k params error]])
context)))))The :after hook runs after the handler commits, so for query-success / query-failure the fresh data is already in app-db — any event you enqueue via :fx will see the updated state.
Interceptors don't have to live forever. Register on route enter, clear on route leave — the interceptor only sees events dispatched while it's installed, so there's no global pollution.
Do the registration and clearing in the route-enter/leave functions themselves (e.g. reitit's :controllers :start/:stop, or whatever your router calls before dispatching its enter/leave events). re-frame events should remain pure data; reg-global-interceptor and clear-global-interceptor are side effects, so they don't belong inside an event handler.
(require '[re-frame.interceptor :as rfi])
(defn books-route-enter []
(rf/reg-global-interceptor
(rf/->interceptor
:id :books/page-telemetry ;; unique id used to uninstall later
:after
(fn [context]
(let [{:keys [event-id k]}
(rfq/parse-result-event (get-in context [:coeffects :event]))]
(if (and (= k :books/list)
(#{:re-frame.query/query-success
:re-frame.query/query-failure} event-id))
(rfi/update-effect context :fx (fnil conj [])
[:dispatch [:analytics/books-event event-id]])
context)))))
(rf/dispatch [::rfq/ensure-query {:query :books/list :params {:page 1}}]))
(defn books-route-leave []
(rf/clear-global-interceptor :books/page-telemetry)
(rf/dispatch [::rfq/mark-inactive {:query :books/list :params {:page 1}}]))
;; Wire into your router. With reitit:
;; {:name :books
;; :controllers [{:start books-route-enter
;; :stop books-route-leave}]}Pair this with polling's route enter/leave pattern — the two use the same lifecycle, so a single pair of route hooks can wire both fetching and observability.
- Analytics / tracing for query completion times across the app.
- Toast-on-failure policies that apply to a whole route or section.
- Post-success cache syncing — e.g. when
:books/listsucceeds, patch a derived:books/countquery. - Debug logging in development (this is exactly what
rfq/enable-debug-logging!does — a global interceptor on all:re-frame.query/*events).
The ensure-query / refetch-query events themselves fire before the HTTP effect does. An :after interceptor on those sees the event after the :db update that marked the query :loading/:fetching? true, which is the natural "start" signal. If you need strict before-effect timing (rare), a :before interceptor sees the event even earlier — but in practice, :loading being in app-db is the observable contract most consumers want.