DEV Community

Kasey Speakman
Kasey Speakman

Posted on • Edited on

ClojureScript async MVU

Going async wasn't so bad...

Last time, I removed the traditional MVU init function from the synchronous MVU library. Instead making it just another case of the update function.

This time, I went async.

Next time I add routing.

core async

I was not very keen to use core async for this problem, because it lacks batching semantics. But then I thought if there was a way to make it work, that would be better than writing all that code myself.

A helpful person in Clojurians slack suggested that I use the poll! method to pull as many items off the channel as I like or until I get nil back. And you know, that probably would have been fine. But it still has some rendezvous overhead versus pulling a batch of items off the channel in one take. Especially considering that unfocused tabs only get control to process events once in a while.

I luckily found a batching buffer implementation for core async. The same helpful person (Thanks Adrian Smith) gave me some pointers on adapting it to ClojureScript. And I did.

(ns mvu.batchingbuffer
  (:require [cljs.core.async.impl.protocols]))

(deftype BatchingBuffer [items n]
  cljs.core.async.impl.protocols/Buffer

  (full? [this]
    (>= (count @items) n))

  (remove! [this]
    (let [[old _] (reset-vals! items [])]
      old))

  (add!* [this item]
    (swap! items conj item))

  (close-buf! [this])

  cljs.core/ICounted
  (-count [this]
    (if (empty? @items)
      0
      1)))

(defn batching-buffer [n]
  (BatchingBuffer. (atom []) n))

;; creation
;; (chan (batching-buffer 1024))

I do not know what decisions are made from -count versus full?. I am consoled that core async isn't aware that I have queue limit, since the buffer is created before it is passed to the channel. So probably it doesn't matter.

Putting it together

Here is the async version of the MVU loop.

WARNING: I have not thoroughly tested or used this code in production. In fact I'm a bit scared because it just worked the first time.

(ns mvu.async
  (:require [mvu.batchingbuffer :refer [batching-buffer]])
  (:require [reagent.dom :as rdom])
  (:require [cljs.core.async :refer [<! >! put! chan go-loop]]))

(defonce event-ch (chan (batching-buffer 1024)))
(defonce effect-ch (chan (batching-buffer 256)))
(defonce render-ch (chan (sliding-buffer 1)))

(defonce state-atom (atom {}))

(defn default-log [event next-model effects]
  (js/console.log (clj->js
                   {:event (filter identity event)
                    :model next-model
                    :effects effects})))

(def defaults {:log default-log
               :init-event [:init]
               :model {}})

(defn batch-update [updatef]
  (fn [[model effects] event]
    (let [[next-model new-effects] (updatef model event)]
      [next-model (into effects new-effects)])))

(defn notify [& event]
  (put! event-ch event))

(defn process-events []
  (go-loop []
    (let [batch (<! event-ch)
          {updatef :update
           log :log
           model :model} @state-atom]
      (when (some? batch)
        (let [[model effects]
              (reduce (batch-update updatef) [model []] batch)]
          (cond goog.DEBUG (log batch model effects))
          (swap! state-atom assoc :model model)
          (>! render-ch true)
          (doseq [effect effects]
            (>! effect-ch effect))
          (recur))))))

(defn process-effects []
  (go-loop []
    (let [effect (<! effect-ch)
          {model :model
           perform :perform} @state-atom]
      (when (some? effect)
        (perform model effect)
        (recur)))))

(defn process-render []
  (go-loop []
    (let [v (<! render-ch)
          {model :model
           render :render
           render-node :render-node} @state-atom]
      (when (some? v)
        (rdom/render (render model) render-node)
        (recur)))))

(defn on-reload [config]
  (let [{log :log
         model :model
         :as state} (merge @state-atom config)]
    (cond goog.DEBUG (log [:hot-reloaded] model []))
    (reset! state-atom state)
    (put! render-ch true)))

(defn create [config]
  (let [state (merge defaults config)]
    (reset! state-atom state)
    (process-render)
    (process-effects)
    (process-events)
    (apply notify (:init-event state))))

I use the batching buffer for event and effect processing. Event processing should have a higher proportional volume. It probably wouldn't hurt to add these numbers to default config and initialize them on start. That way the user could provide them in their config to override. The render channel uses a sliding buffer of 1 because it does not make sense to queue renders considering how MVU works.

I had to get rid of the namespaced keys. I could not get them to work correctly after I moved the files into a subfolder and changed the namespace. Just my unfamiliarity, but I won't worry about it for now.

The app code is identical to the synchronous version except for namespace changes.

(ns core
  (:require [mvu.async :as mvu]))

(defn updatef [model [tag data :as event]]
  (case tag
    :init [{:count 0} []]
    :clicked [(update model :count inc) []]
    [model []]))

(defn render [{count :count :as model}]
  [:div
   [:button {:type "button" :on-click #(mvu/notify :clicked)} "Click me"]
   [:div "Count " count]])

(defn perform [model effect]
  nil)

(def config {:update updatef
             :render render
             :render-node (js/document.getElementById "app")
             :perform perform})

;; shadow-cljs hot reload hook
(defn ^:dev/after-load on-reload []
  (mvu/on-reload config))

(defn main [& args]
  (mvu/create config))

After I dealt with the namespaced keys, it... just worked?! It even hot reloads the same.

Conclusions

Frankly there is nothing wrong with using the synchronous version. After some vague remembrance and quick search to verify, I found that Redux uses synchronous dispatch by default. So it is probably fine for most cases.

But it was awesome to get the async version minimally working. I am new to Clojure so I still need to build confidence in the code I write. I will keep playing with the async version to see if I can find some holes. Feel free to point out any obvious issues.

Top comments (0)