Skip to content

Effects: the way out

So far, handlers have returned {:db …}. Real apps also need storage, HTTP, timers, and follow-up events, and an event handler still has to stay pure: same inputs, same output. Testing, replay, and time-travel all depend on that.

The answer is the pattern you have used since events. A handler never does anything. It returns a description of what should happen, as plain data, and the runtime performs it.

Saving todos

The todo list forgets everything on reload. The obvious fix is to write to localStorage from the handler:

;; Don't do this
(rf/reg-event :todo/toggle
  (fn [{:keys [db]} [_ id]]
    (let [todos (update-in (:todos db) [id :done?] not)]
      (.setItem js/localStorage "todos" (pr-str todos))   ;; I/O inside a handler
      {:db (assoc db :todos todos)})))

That write costs you three things:

  • The handler isn't pure any more. Testing it now means faking localStorage.
  • Replay repeats the write. Replaying the recorded events writes to storage again, and nothing in the event history shows that a write happened.
  • It breaks off the browser. During server-side rendering there is no localStorage, so the handler throws.

Instead, the handler describes the write, and one registered effect handler performs it:

(require '[re-frame.core :as rf])

;; the new idea: the one place in the app that writes to localStorage
(rf/reg-fx :todo.storage/save
  {:doc       "Write the todos to localStorage."
   :platforms #{:client}}
  (fn [_ctx todos]
    (.setItem js/localStorage "todos" (pr-str todos))))

(defn next-id [todos]
  (inc (apply max 0 (keys todos))))

(rf/reg-event :todo/initialise
  (fn [_ _] {:db {:todos {} :showing :all}}))

(rf/reg-event :todo/add
  (fn [{:keys [db]} [_ title]]
    (let [id    (next-id (:todos db))
          todos (assoc (:todos db) id {:id id :title title :done? false})]
      {:db (assoc db :todos todos)
       :fx [[:todo.storage/save todos]]})))

(rf/reg-event :todo/toggle
  (fn [{:keys [db]} [_ id]]
    (let [todos (update-in (:todos db) [id :done?] not)]
      {:db (assoc db :todos todos)
       :fx [[:todo.storage/save todos]]})))

(rf/reg-sub :todo/todos (fn [db _] (:todos db)))
(rf/reg-sub :todo/all {:inputs [[:todo/todos]]}
  (fn [[todos] _] (vec (sort-by :id (vals todos)))))

(rf/reg-view todo-list []
  [:div
   [:button {:on-click #(dispatch [:todo/add (rand-nth ["Buy milk" "Walk the dog" "Call mum"])])}
    "Add a todo"]
   [:ul
    (for [{:keys [id title done?]} @(subscribe [:todo/all])]
      ^{:key id}
      [:li {:on-click #(dispatch [:todo/toggle id])
            :style    {:text-decoration (when done? "line-through")}}
       title])]])

[rf/frame-root {:id :app :initial-events [[:todo/initialise]]}
 [todo-list]]

Mount it in your app, add a todo, then open your browser's storage inspector: the todos key changes on every add and toggle.

The handlers never touch storage. Each returns an extra key, :fx, holding a row [:todo.storage/save todos] that describes the write. The runtime commits :db, then looks up :todo.storage/save and calls its handler with the row's argument. That row is an effect.

reg-fx takes an id, a metadata map, and a function of two arguments. The second is the row's argument, here the todos map. The first is a small context map, which this handler ignores (Advanced covers it).

:platforms #{:client} tells the runtime to skip this effect during server-side rendering, so the handler never branches on platform.

What you gain:

  • The handlers are pure again. A test calls one and asserts on the returned map, with no storage involved.
  • The write shows up in the trace stream and in Xray, and a test can redirect it by id (Stubbing effects in tests).
  • reg-fx is the only code that touches js/localStorage. Keep effect handlers this small: they are the hardest functions in the app to test, so put the logic in the pure handler that builds the arguments.

Reading the saved todos back in at startup is the other direction, and it is the subject of Coeffects.

The effect map

A handler returns an effect map with two top-level keys:

Key Meaning
:db Replace app-db with this value.
:fx A vector of [fx-id arg] rows. Each names a registered effect and passes it one argument. Every other effect goes here: a storage write, an HTTP request, a follow-up dispatch, a navigation.

(App-db also taught the privacy classification keys, :sensitive, :large and their clear- counterparts, which may sit beside these two.)

Because :fx is a vector, one handler can ask for several things:

(rf/reg-event :todo/clear-done
  (fn [{:keys [db]} _]
    (let [todos (into {} (remove (comp :done? val)) (:todos db))]
      {:db (assoc db :todos todos)
       :fx [[:todo.storage/save todos]
            [:dispatch [:todo/set-showing :all]]]})))

:dispatch is a built-in effect that queues a follow-up event. Never call dispatch from inside a handler body; return a :dispatch row and the runtime queues it. For a delayed dispatch, return [:dispatch-later {:ms 3000 :event [:todo/hide-notice]}] instead of calling js/setTimeout.

A row may be [fx-id] when the effect takes no argument. A nil row is skipped, so (when saving? [:todo.storage/save todos]) makes a row conditional.

Ordering and atomicity — what you can rely on

When a handler returns {:db new-db :fx [[a 1] [b 2] [c 3]]}:

  1. :db commits first, in one step, before any :fx row runs. No subscription or other reader ever sees a half-written app-db.
  2. :fx rows run in order. [a 1], then [b 2], then [c 3].
  3. Each row finishes before the next starts. Async work a row starts, such as a request or a :dispatch-later timer, is not awaited. "Finishes" means its effect handler returned.
  4. Effects see the new state. A [:dispatch [:next-step]] row queues an event whose handler reads the app-db this handler just committed. That is how you chain steps: write state, then dispatch the event that builds on it.
  5. A row that throws doesn't stop the others, and nothing rolls back. Each failure is reported as :rf.error/fx-handler-exception, and the committed :db stays. When one step depends on another succeeding, have the first report its outcome as an event (as :rf.http/managed does with :on-success) and run the second from that event's handler.

Run to completion

:todo/clear-done changed app-db and queued a follow-up event. When does the screen update: after the first event, or after both?

After both. When the runtime starts processing events, it drains the queue to completion before any view re-renders. The dequeued event runs its handler and commits its app-db write, then any events it dispatched run theirs, and so on until the queue is empty. Only then do views render, once. Every dispatch behaves this way, and there is no opt-out.

One click here dispatches :todo/add-samples, which queues three :todo/add events: four pipeline runs, one render. Click into the cell, press Ctrl-Enter (Cmd-Enter on macOS) to evaluate, then click the button:

(require '[re-frame.core :as rf])

(rf/reg-event :todo/initialise
  (fn [_ _] {:db {:todos {} :showing :all}}))

(rf/reg-event :todo/add
  (fn [{:keys [db]} [_ title]]
    (let [id (inc (apply max 0 (keys (:todos db))))]
      {:db (assoc-in db [:todos id] {:id id :title title :done? false})})))

;; one event that fans out into three more
(rf/reg-event :todo/add-samples
  (fn [_ _]
    {:fx [[:dispatch [:todo/add "Buy milk"]]
          [:dispatch [:todo/add "Walk the dog"]]
          [:dispatch [:todo/add "Call mum"]]]}))

(rf/reg-sub :todo/todos (fn [db _] (:todos db)))
(rf/reg-sub :todo/all {:inputs [[:todo/todos]]}
  (fn [[todos] _] (vec (sort-by :id (vals todos)))))

(rf/reg-view sample-list []
  [:div
   [:button {:on-click #(dispatch [:todo/add-samples])} "Add samples"]
   [:ol
    (for [{:keys [id title]} @(subscribe [:todo/all])]
      ^{:key id} [:li title])]])

[rf/frame-root {:id :todos :initial-events [[:todo/initialise]]}
 [sample-list]]

All three todos appear together. The view never renders a list with only "Buy milk" in it.

Three details:

  1. Each dequeued event is its own epoch. The parent and its three children are four epochs in the history, even though they rendered together.
  2. Async effects are not drained. An HTTP request started during the drain does not delay the render. Its reply arrives later as a new event, in a new drain.
  3. The drain is per frame. With one frame, the normal case, that means per app.

Drain-depth limits, dispatch-sync, and what destroy-frame! does to a running drain are on Run to completion. You don't need them to use :fx.

For JavaScript developers

React batches the state updates inside one event handler and paints once at the end. Run to completion takes that further: the smallest unit that renders is an entire settled drain, not one handler. As in React, the batch closes at the next microtask checkpoint, so two drains that finish in the same task can render together. You never need flushSync for app work, and you never see the UI between synchronous follow-ups.

HTTP

A request is an effect like any other. The handler describes it, and the :rf.http/managed effect performs it. This cell answers its requests from a stub instead of a server. Click Fetch todos, then Fetch archived todos:

(require '[re-frame.core :as rf]
         '[re-frame.http.managed]
         '[re-frame.http.test-support :as http-test-support])

(rf/reg-event :todo/fetch
  (fn [{:keys [db]} [_ url]]
    {:db (assoc db :loading? true :fetch-error nil)
     :fx [[:rf.http/managed
           {:request    {:method :get :url url}
            :decode     :json
            :on-success [:todo/fetched]
            :on-failure [:todo/fetch-failed]}]]}))

;; On success, the reply's :value is the decoded body: here a vector of todos.
(rf/reg-event :todo/fetched
  (fn [{:keys [db]} [_ {:keys [value]}]]
    {:db (assoc db :loading? false
                   :todos    (into {} (map (juxt :id identity)) value))}))

;; On failure, the reply's :error is a map whose :kind names what went wrong.
(rf/reg-event :todo/fetch-failed
  (fn [{:keys [db]} [_ {:keys [error]}]]
    {:db (assoc db :loading? false :fetch-error error)}))

;; a stand-in server: canned replies keyed by [method url]
(http-test-support/install-managed-request-stubs!
  {[:get "/api/todos"]          {:reply {:ok [{:id 1 :title "Buy milk"     :done? false}
                                              {:id 2 :title "Walk the dog" :done? true}]}}
   [:get "/api/todos/archived"] {:reply {:failure {:kind :rf.http/http-4xx :status 404}}}})

(rf/reg-sub :todo/loading?    (fn [db _] (:loading? db)))
(rf/reg-sub :todo/fetch-error (fn [db _] (:fetch-error db)))
(rf/reg-sub :todo/todos       (fn [db _] (:todos db)))
(rf/reg-sub :todo/all {:inputs [[:todo/todos]]}
  (fn [[todos] _] (vec (sort-by :id (vals todos)))))

(rf/reg-view todo-fetcher []
  (let [error @(subscribe [:todo/fetch-error])]
    [:div
     [:button {:on-click #(dispatch [:todo/fetch "/api/todos"])} "Fetch todos"]
     [:button {:on-click #(dispatch [:todo/fetch "/api/todos/archived"])}
      "Fetch archived todos"]
     (cond
       @(subscribe [:todo/loading?]) [:p "Loading…"]
       error [:p "Failed: " (str (:kind error)) ", status " (:status error)])
     [:ul
      (for [{:keys [id title]} @(subscribe [:todo/all])]
        ^{:key id} [:li title])]]))

;; :fx-overrides sends this frame's :rf.http/managed rows to the stub
[rf/frame-root {:id           :todos/http
                :fx-overrides {:rf.http/managed :rf.http/managed-test-stub}}
 [todo-fetcher]]

The runtime performs the request. When the reply arrives, it dispatches :on-success or :on-failure as a new event, with the reply map appended as the last argument. That map is the uniform reply: success carries :value, failure carries :error, and every managed async effect answers the same way. Here the first reply reaches :todo/fetched; the second is a 404, so it reaches :todo/fetch-failed with :kind :rf.http/http-4xx.

The stub comes from re-frame.http.test-support. install-managed-request-stubs! registers an effect that answers from the map, and the frame's :fx-overrides routes :rf.http/managed to it, so the handlers are the ones a real app runs. A stub replies at once, inside the same drain, so Loading… never renders here; against a real server it shows while the request is in flight.

All three handlers are pure. Each tests as a plain function, and the request tests as data: assert on the :fx row, with no network.

:rf.http/managed ships in the HTTP artefact, day8/re-frame2-http. Require re-frame.http.managed once at boot; without it, :rf.http/managed is an unregistered effect and the row fails with :rf.error/no-such-fx.

Don't call js/fetch inside a handler. Besides the problems the storage write had, the .then callback runs after the handler has returned, so it has no way to produce new state.

Retries, reply categories, and aborts are covered in Managed HTTP. For caching and staleness, use resources, which build on this effect. Runnable examples: examples/core/managed_http_counter and RealWorld HTTP.

Coming from Redux?

The :fx vector is where thunks, sagas, and middleware used to live, except the handler stays a pure function returning data and the runtime interprets the effects. The async reply doesn't resolve a promise a reducer is awaiting; it arrives as a new action on the same queue.

Coming from TanStack Query?

A bare :rf.http/managed row is the low-level form: one request and its two reply events, wired by hand. For caching, staleness, and deduplication, use resources, which play the role of useQuery.

Troubleshooting

Symptom Cause Fix
:rf.error/effect-map-shape; nothing applied, not even :db A top-level key other than :db / :fx: a typo like :dn, app-db returned without {:db …}, or a re-frame v1 top-level :dispatch Put every effect in an :fx row
:rf.error/effect-map-shape naming one :fx entry; the other rows run That entry is not an [fx-id arg] vector Wrap it: [:dispatch [:saved]], not a bare :dispatch
:rf.error/no-such-fx; that row fails, the others run The :fx row names an unregistered id Register it with reg-fx, or fix the typo
:rf.error/fx-handler-exception; later rows still run An effect handler threw; :db is already committed Chain dependent steps through reply events
Handler calls dispatch or does I/O directly The handler is no longer pure, and the epoch history misses the work Return an :fx row ([:dispatch …], or your own reg-fx id)
:rf.error/no-such-fx naming :rf.http/managed The HTTP artefact isn't loaded Add day8/re-frame2-http and require re-frame.http.managed
:rf.error/no-frame-context from an async callback A bare dispatch in a callback that runs later Call rf/capture-frame in the effect handler and reply through its dispatch (below)

Advanced

The effect handler's two arguments

The first argument to an effect handler is a context map carrying :frame, the frame the originating event ran in, and :event, the originating event vector. It is not the world map an event handler receives, and has no :db. An effect that needs state gets it in its argument, or reads it at run time with (rf/app-db-value frame).

You need :frame when an effect dispatches back later. A page can run several frames, and a callback that fires after the effect handler has returned no longer knows which one it belongs to. Capture the frame on entry with rf/capture-frame, and have each callback reply through the dispatch it returns:

(rf/reg-fx :todo.api/save
  {:doc       "POST the todos, then dispatch the outcome into the originating frame."
   :platforms #{:client}}
  (fn [ctx {:keys [todos on-success on-failure]}]
    (let [{:keys [dispatch]} (rf/capture-frame (:frame ctx))]   ;; on entry
      (-> (js/fetch "/api/todos" #js {:method "POST" :body (pr-str todos)})
          (.then  #(dispatch on-success))
          (.catch #(dispatch on-failure))))))

A bare (rf/dispatch …) in the .then raises :rf.error/no-frame-context (frame identity is carried, not found). The captured dispatch is bound to this frame instance, not just its id. If the frame is destroyed before the request settles, the reply is dropped with :rf.error/frame-destroyed, even when a new frame has since been made under the same id. (rf/dispatch on-success {:frame (:frame ctx)}) is not the same: it delivers to whichever frame holds that id when the promise settles. That form suits a dispatch made while the effect handler is still running.

In practice you would use :rf.http/managed, :dispatch, or :dispatch-later, which carry the frame for you; this example only shows the rule. The dispatch a view receives from reg-view is already captured (Frames).

Registration details

  • An :fx row naming an unregistered id fails loud with :rf.error/no-such-fx, reported through the always-on error listener.
  • Registration order across files doesn't matter. The effect is looked up when the row runs, not when the event handler is registered.
  • :platforms holds :client, :server or both; omit it and the effect runs on both. A skipped row emits a :rf.fx/skipped-on-platform trace event.
  • Treat an effect's argument as an API you are designing, and prefer explicit keys.

Stubbing effects in tests

A registered effect is addressable by id, so a test can replace it without touching the handler under test: pass :fx-overrides in the dispatch options, or set them on the frame. See Testing event handlers.