Skip to content

Why no await: continuations are data

An event handler returns effects and finishes. When an async effect completes, it dispatches another event. That reply handler receives the current app-db, and its state change passes through the same event pipeline as a button click.

Name the receiving event

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

{:fx [[:rf.http/managed
       {:request    {:url "/api/articles/intro"}
        :on-success [:article/loaded]
        :on-failure [:article/load-error]}]]}

The continuation is what should happen after the result arrives. Here its address is [:article/loaded] or [:article/load-error]. The runtime appends a reply map and dispatches that event. You can put context in the vector too: :on-success [:article/loaded slug] delivers [:article/loaded slug reply].

In this cell, stubs stand in for the server, and each receiving handler keeps the whole event vector it was dispatched with. Load each article to see the reply map appended after the slug:

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

(http-test-support/install-managed-request-stubs!
  {[:get "/api/articles/intro"]   {:reply {:ok {:slug "intro" :title "Welcome"}}}
   [:get "/api/articles/missing"] {:reply {:failure {:kind :rf.http/http-4xx :status 404}}}})

(rf/reg-event :article/load
  (fn [_ [_ slug]]
    {:fx [[:rf.http/managed
           {:request    {:url (str "/api/articles/" slug)}
            :on-success [:article/loaded slug]
            :on-failure [:article/load-error slug]}]]}))

(rf/reg-event :article/loaded
  (fn [{:keys [db]} event]
    {:db (assoc db :article/last-event event)}))

(rf/reg-event :article/load-error
  (fn [{:keys [db]} event]
    {:db (assoc db :article/last-event event)}))

(rf/reg-sub :article/last-event (fn [db _] (:article/last-event db)))

(rf/reg-view continuation-view []
  [:div
   [:button {:on-click #(dispatch [:article/load "intro"])} "Load intro"]
   [:button {:on-click #(dispatch [:article/load "missing"])} "Load missing"]
   [:pre (pr-str @(subscribe [:article/last-event]))]])

;; :fx-overrides sends this frame's requests to the stubs. A real app leaves it out.
[rf/frame-root {:id :app/articles :fx-overrides {:rf.http/managed :rf.http/managed-test-stub}}
 [continuation-view]]

Decisions use the current app-db

A callback can accidentally close over the db that existed when work started. By the time its result arrives, the user may have selected another article. A separate reply handler gets the app-db at delivery time:

(rf/reg-event :article/load
  (fn [{:keys [db]} [_ slug]]
    {:db (assoc db :article/viewing slug)
     :fx [[:rf.http/managed
           {:request    {:url (str "/api/articles/" slug)}
            :request-id :article/load
            :on-success [:article/loaded slug]
            :on-failure [:article/load-error slug]}]]}))

(rf/reg-event :article/loaded
  (fn [{:keys [db]} [_ slug {:keys [value]}]]
    (if (= slug (:article/viewing db))
      {:db (assoc db :article/data value :article/error nil)}
      {})))

(rf/reg-event :article/load-error
  (fn [{:keys [db]} [_ slug {:keys [error]}]]
    (if (= slug (:article/viewing db))
      {:db (assoc db :article/error error)}
      {})))

The slug records which article the request asked for; db tells the handler which article is selected now. The stable :request-id additionally supersedes an earlier load in this frame, so an older request cannot overwrite a newer load of the same slug. Navigating away without another request can still change :article/viewing, which the handlers check.

The event model does not make stale data impossible. You can still pass an old snapshot in an event and misuse it. Make decisions from the receiving handler's current coeffects, and carry only the request context it needs. Managed HTTP provides request-id and lifetime checks; a custom async effect needs its own correlation rules.

What the runtime can inspect

A request description and its reply address are ordinary data. The runtime can record which effect was issued, show the request in Xray, and record the reply event in the ledger. Tests can supply the reply without resuming a suspended function.

The event id is resolved when the reply is handled, so re-registering that handler during development changes how an in-flight request's reply is processed. This does not persist pending work across a page reload. Sockets, timers and requests are host work; serializing an event vector does not recreate them. Restoring an epoch or destroying a frame aborts its managed HTTP work and suppresses the reply.

One reply map under every async surface

Managed HTTP and resources and mutations use the uniform reply. Its status vocabulary is:

:status Meaning
:ok Success, with the result in :value.
:partial Usable data with structured problems. Managed HTTP does not emit this status.
:error Failure, with details in :error. A timeout is an error, not a separate status.
:cancelled Cancellation of current work; a live managed reply names the cancel reason.
:stale Obsolete completion. Recorded by the runtime and suppressed before app dispatch.

An HTTP reply handler therefore handles :ok, :error and :cancelled. A superseded request never reaches it. The HTTP reference lists the identity and timing fields of a live reply; stubs omit those fields.

This common runtime model does not make every public completion payload identical. A managed HTTP child machine sends its parent [:succeeded value] or [:failed failure]. A machine's :on-done callback receives its declared result. Use the documented completion form of the surface you call.

Record completion time from the reply's :completed-at or HTTP's recorded :rf/time-ms coeffect, rather than reading a new clock value during replay.

Coming from Promises

Promise operation Event-based equivalent
.then(onFulfilled) HTTP :on-success [:loaded], or the :ok branch of a :reply-to handler.
.catch(onRejected) HTTP :on-failure [:load-error], or the :error branch.
.finally(onFinally) Shared code in the reply handler, for every outcome actually delivered.
AbortController :abort-signal in the browser, or :rf.http/managed-abort by request id on either host.
Promise.all HTTP child machines under :spawn-all with :join :all.

The .finally job

Use :reply-to when success, failure and cancellation share cleanup:

(rf/reg-event :article/replied
  (fn [{:keys [db]} [_ reply]]
    (let [db (assoc-in db [:article :loading?] false)]
      (case (:status reply)
        :ok        {:db (assoc-in db [:article :data] (:value reply))}
        :error     {:db (assoc-in db [:article :error] (:error reply))}
        :cancelled {:db db}))))

The request supplies :reply-to [:article/replied]. A reply target need not be the issuing event: separate send and receive events are often easier to read. When you do use one event for both, test explicitly for the absence of a reply before issuing work, so cancellation cannot start it again.

Why there is no :on-finally

A stale completion must not clear a loading flag that now belongs to a newer request. Its reply is suppressed, including any cleanup the handler would have done. Frame teardown similarly delivers no reply. Put cleanup that must happen on teardown in the owning lifecycle rather than in a reply handler.

The honest trade

A short async/await function can express several dependent steps in one place. Events give each step a name and a handler, which costs more registrations. For a single request that is usually manageable. For a workflow with branching, cancellation or dependent requests, a state machine keeps the states and transitions together while effects still return through events.

Promises remain useful inside effect implementations. The separation is between host work in the effect and state changes in the event handler.