Side Effects, Get Out of My Yard

This post describes how to write re-frame middleware to replace side-effectful function calls with data.

I’m currently working on a medium-sized ClojureScript web app. Since om.next is still in alpha, I opted for reagent/re-frame instead. Coming form React/Redux I felt right at home, especially since I ran into the same kind of problems, like where to perform HTTP requests (action creators!?) and how to do optimistic updates.

Performing HTTP request in the action creators (Redux) or the handlers (re-frame) just feels wrong. The “mistake” is even advocated in the re-frame wiki:

(register-handler
  :request-it             ;; <-- the button dispatched this id
  (fn
    [db _]
    ;; kick off the GET,
    ;; making sure to supply a callback for success and failure
    (GET "http://json.my-endpoint.com/blah"
      {:handler       #(dispatch [:process-response %1]) ;; further dispatch !
       :error-handler #(dispatch [:bad-response %1])})   ;; further dispatch !
    ;; update a flag in `app-db` ... presumably to trigger UI changes
    (assoc db :loading? true)))    ;; pure handlers must return a db

(Fun fact: Note how it says “pure handler” in the comment, even though that’s a textbook example of an impure function)

From a practical standpoint this makes the handler untestable without overwriting the GET function somehow, from a philosophical standpoint these are side effects which make the handler impure (which is, you know, “bad”).

Since we’re already talking about religion, in Clojure we believe in data > functions > macros, so let’s do that. Currently we are using functions, so why don’t we return data from our handlers? In fact we are already doing that. We return a new state of the db, which will eventually lead to updates in the DOM. However, when it comes to other side effects we are left on our own1.

Luckily re-frame lets us write middleware. Using middleware we can rewrite this example using just data:

(register-handler
  :request-it
  [(enrich :db) http-driver] ;;!! (1)
  (fn [db _]
    {:db    (assoc db :loading? true) ;;!! (2)
     :http  {:method            :get  ;;!! (3)
             :url               "http://json.my-endpoint.com/blah"
             :with-credentials? false
             :meta              {:steps [[:process-response] ;;!!2 (4)
                                         [:bad-response]]}}}))

Obviously we are now relying on the http-driver middleware (1) to perform the request for us. The good thing is that this middleware can be written and tested independently by Somebody Else™ and conform to a contract. In fact the map in (3) is what you pass to the request function in cljs-http.

We are now returning data for two different side-effect backends: The db (2) and the http middleware (3). There could be more, like for writing to localStorage. Anyway, in order to interoperate with re-frame we need limit the scope to :db after we are done with the side-effects. This is what (enrich :db) (1) is for.

It’s not entirely true that the :http map is what you would pass to cljs-http, because there’s the additional :meta field (4). This is for interoperability as well. The driver needs to know which value to dispatch when the response arrives. The way the http-driver is written, it will conj the body of the response to the vector provided in :steps.

This is also how you would pass data to the follow-up handlers, which comes in handy for optimistic updates:

(register-handler
  :new-foo
  [(path [:to :foos]) (enrich :db) http-driver trim-v]
  (fn [foos [new-foo]]
    (let [temp-id (gen-temp-id)]
              ; optimistically add to the list and assign temp id
      {:db    (conj foos (assoc new-foo :id temp-id))
              ; perform POST request
       :http  {:method            :post
               :url               "/foo")
               :transit-params    new-foo
               :with-credentials? false
                                  ; add the temp-id to the dispatch value
               :meta              {:step [[:new-foo-success temp-id]
                                          [:new-foo-failure temp-id]]}}})))
(register-handler
  :new-foo-success
  [(path [:to :foos]) trim-v]
  (fn [foos [temp-id new-foo]] ; body of the response get's conj'd to the end
    ; replace the temp foo with the one returned from the server
    (->> foos (map #(if (= (:id %) temp-id) new-foo %)))))

(register-handler
  :new-foo-failure
  [(path [:to :foos]) trim-v]
  (fn [foos [temp-id error]]
    ; remove the temp foo from the list
    (->> foos (remove #(= (:id %) temp-id)))))

(Fun fact: This isn’t pure either, because gen-temp-id is non-deterministic)

Remember how I said earlier that Somebody Else™ could write such a middleware, potentially as part of a library like redux-effects? Unfortunately that somebody isn’t me. Here is the implementation of the http-driver anyway. However, it lacks features like performing multiple requests or extracting an error object/message in the failure case:

(defn http-driver
  "Middleware for re-frame that performs http requests."
  [handler]
  (fn [db v]
    (let [{:keys [http] :as result} (handler db v)]
      (when (some? http)
        (go
          (let [response               (<! (request http))
                {:keys [success body]} response
                [success-s failure-s]  (get-in http [:meta :steps] [])]
            (if success
              (when success-s
                (dispatch (conj success-s body)))
              (when failure-s
                (dispatch (conj failure-s response)))))))
      result)))

Acknowledgements

  1. To me that’s a typical example of the domain-specificity of knowledge (as expressed by Nassim Taleb): We understand that data representing side-effects is easier to reason about when talking about the DOM (= React), but it’s not immediately apparent that you could do the same for other domains as well, for the same reasons. ↩︎


© 2022 Florian Klampfer

Powered by Hydejack v9.1.6