Events, Effects, Coeffects, Interceptors

reg-event-db vs reg-event-fx

  • reg-event-db: (fn [db event-vector] new-db). One coeffect (db), one effect (new db). Pure. Default choice (~90% of handlers).
  • reg-event-fx: (fn [cofx event-vector] effects-map). cofx always contains :db plus any injected coeffects. Use only when the handler needs extra world inputs (now, random, LocalStore) or must cause non-db effects (HTTP, dispatch, timers).
(reg-event-db :auth/set-token (fn [db [_ t]] (assoc-in db [:auth :token] t)))
;; equivalent -fx form:
(reg-event-fx :auth/set-token (fn [{:keys [db]} [_ t]] {:db (assoc-in db [:auth :token] t)}))

Both take an optional interceptor vector as second arg: (reg-event-fx :id [interceptors] handler). Both silently prepend standard interceptors: do-fx (actions the effects map at chain end) and (inject-cofx :db) (why :db is always present).

Common mistake: returning modified cofx from a -fx handler ("no handler registered for effect: :event"). Return a fresh effects map: {:db (update db :clicks inc)}.

Effects

Effects map: each key names an effect, value is its payload. Since v1.1.0, return only :db and :fx:

{:db new-db                                    ;; always actioned first
 :fx [[:dispatch [:some/event "extra"]]        ;; then :fx entries, in order
      [:http-xhrio {:method :GET :url "..."}]
      (when condition [:full-screen true])]}   ;; nils ignored → conditional effects

Built-in effects:

  • :db — reset app-db to the value.
  • :fx — vector of [effect-id payload] tuples, actioned in order; nils skipped.
  • :dispatch — one event vector. For multiple, use multiple :dispatch tuples in :fx.
  • :dispatch-later{:ms 200 :dispatch [:event-id "param"]}.
  • :dispatch-n — deprecated since v1.1.0.
  • :deregister-event-handler — event id(s) to remove.

reg-fx (custom effects)

(reg-fx
 :local-store/save                 ;; effect key
 (fn [{:keys [key value]}]         ;; called with payload; return value ignored
   (.setItem js/localStorage key (pr-str value))))
  • Keep effect handlers dumb — no logic. They are the impure edge; hard to test.
  • Data-less effects take nil payload: {:exit-fullscreen nil}.
  • Document the payload shape — each effect is a nano-DSL.
  • Effect handlers live in .cljs (they touch the platform); the event handlers describing them stay in .cljc.

Coeffects

Coeffects = data the handler needs from the world (time, localstorage, random). Inject them as data so the handler stays pure and testable.

(reg-cofx :now (fn [cofx _] (assoc cofx :now (js/Date.))))

(reg-cofx :local-store
  (fn [cofx k] (assoc cofx :local-store (.getItem js/localStorage k))))

(reg-event-fx
 :settings/load-defaults
 [(inject-cofx :local-store "defaults-key") (inject-cofx :now)]
 (fn [{:keys [db local-store now]} _]
   {:db (assoc-in db [:settings :defaults] local-store)}))

In tests, re-register the cofx with a fixed value ((reg-cofx :now (fn [cofx _] (assoc cofx :now fixed-date)))) and restore with make-restore-fn (see testing reference).

Interceptors

Chain of maps wrapping the handler — data-based middleware. A context {:coeffects {...} :effects {...} :queue ... :stack ...} threads forward through every :before (builds coeffects; handler runs last, return goes into :effects), then backwards through every :after (processes effects; do-fx actions them at the very end).

Built-ins (all from re-frame.core):

  • debug — logs event + clojure.data/diff of db before/after. Dev only: (when ^boolean goog.DEBUG debug).
  • trim-v — drops the event id so handlers destructure [id & args] without the leading _.
  • unwrap — for map-payload events [:event {:x 1}], hands the handler the map directly.
  • (path :cart) — handler sees and returns only that subtree of app-db (like update-in).
  • (after f) — run (f db) for side effects post-handler; ideal for schema validation. For -fx handlers :db may be absent: (after #(when % (validate! %))).
  • (enrich f) — post-handler db computation/validation.
  • on-changes — precursor to Flows.

Interceptor vectors are flattened and nils removed — compose freely: [standard-interceptors (path :cart) trim-v].

Custom interceptor:

(def log-event
  (rf/->interceptor
   :id :log-event
   :before (fn [context]
             (js/console.log (rf/get-coeffect context :event))
             context)))

Global interceptors (run for every event): (rf/reg-global-interceptor log-event).

Talking to servers

Pattern: view dispatches intent → -fx handler sets a loading flag and describes the request → success/failure callbacks are further dispatches (never close over db in async callbacks). Use day8.re-frame.http-fx (requiring the ns registers :http-xhrio):

(ns app.cart.events
  (:require [ajax.core :as ajax]
            [day8.re-frame.http-fx]
            [re-frame.core :as rf]))

(rf/reg-event-fx
 :cart/fetch
 (fn [{:keys [db]} _]
   {:db (assoc-in db [:cart :loading?] true)
    :fx [[:http-xhrio {:method          :get
                       :uri             "/api/cart"
                       :response-format (ajax/json-response-format {:keywords? true})
                       :on-success      [:cart/fetch-succeeded]     ;; response conj'd on
                       :on-failure      [:cart/fetch-failed]}]]}))

(rf/reg-event-db
 :cart/fetch-succeeded
 (fn [db [_ response]]
   (-> db (assoc-in [:cart :loading?] false) (assoc-in [:cart :items] response))))

Always register the failure handler too.

Loading initial data

app-db starts as {}; only event handlers change it — even initial values:

(rf/reg-event-db :app/initialise (fn [_ _] db/initial-db))

Simple app — dispatch-sync before render so state exists before any view mounts:

(defn init []
  (rf/dispatch-sync [:app/initialise])
  (mount!))

Async data (from servers) — plain dispatch + a guard component:

(rf/reg-sub :app/initialised? :-> #(not (empty? %)))

(defn top-panel []
  (if @(rf/subscribe [:app/initialised?])
    [main-panel]
    [:div "Loading..."]))