Logo

dev-resources.site

for different kinds of informations.

ClojureScript async MVU

Published at
8/12/2020
Categories
clojurescript
mvu
async
Author
kspeakman
Categories
3 categories in total
clojurescript
open
mvu
open
async
open
Author
9 person written this
kspeakman
open
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.

clojurescript Article's
30 articles in total
Favicon
Querido Yo del Futuro: Hoy intentaremos configurar una aplicación fullstack en Clojure
Favicon
Why I chose Clojure/Script for building Vade Studio
Favicon
Converting JS Libraries to Clojure: Part 1
Favicon
Deploy your ClojureScript App to Cloudflare Workers
Favicon
shadow-cljs and running tests
Favicon
Giving new life to existing Om legacy SPAs with re-om
Favicon
Is Clojure the only language you need?
Favicon
Building an Application with ClojureScript
Favicon
How to Set up A Clojure Script and Phoenix Project
Favicon
How to create a library that works with Clojure and ClojureScript
Favicon
Setup shadow-cljs react project
Favicon
Logging readable ClojureScript (.cljs) errors to sentry!!
Favicon
How can I create a ClojureScript web app from scratch with Reagent and npm?
Favicon
Set up SSL/TLS for shadow-cljs https server
Favicon
ClojureScript on Cloudflare Workers
Favicon
Storybook.JS with Shadow-CLJS
Favicon
World Statistics Exercise
Favicon
The Pleasure of Clojure(Script): Part 1
Favicon
Using Specter on tree data structures in Clojure
Favicon
Clojure Re-Frame Exercise
Favicon
¿Por qué Clojure?
Favicon
Implementing the feed
Favicon
Try something new this week
Favicon
ClojureScript async MVU
Favicon
ClojureScript simple MVU loop
Favicon
Integrating ClojureScript with JavaScript tooling
Favicon
Understanding Transducers in JavaScript
Favicon
Casting visual spells with p5.js and ClojureScript, part 1
Favicon
ClojureScript REPL Workflow
Favicon
Developing, Testing and Deploying AWS Lambda Functions written in ClojureScript

Featured ones: