In a previous article, I setup a ClojureScript dev environment in a container with VS Code.
Update: I refined the code presented here. But do read the article below for more context.
Today I managed to setup a simple Model-View-Update loop in ClojureScript.
My MVU loop is quite small, only about 50 narrow lines of code. More than half is destructuring data rather than logic. One of the things I love about MVU is that it is quite simple at base.
But first, here is a simple Click Me Counter app that organizes itself around the MVU loop. The perform
function is impure, but init
and updatef
are pure. And render
is close enough.
(ns core
(:require [mvu]))
(defn init [arg]
[{:count 0} []])
(defn updatef [model [tag data :as event]]
(case tag
:clicked [(update model :count inc) []]
[model []]))
(defn render [{name :name 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 {:mvu/init init
:mvu/init-arg nil
:mvu/update updatef
:mvu/render render
:mvu/render-node (js/document.getElementById "app")
:mvu/perform perform})
; shadow-cljs hook for hot reloading
(defn ^:dev/after-load on-reload []
(mvu/on-reload config))
(defn main [& args]
(mvu/create config))
I chose slightly different names from Elm. Instead of Msg, I call it event. Because that is what it is... a new fact that your code handles. In typical MVU, "view" is a function and also the name we use when talking about data returned from that function, which can be confusing. So I chose to call the function "render", taking a cue from React. Elm is missing the abstractions to deal with side effects, instead offloading the responsibility to Javascript. But here "perform" provides that capability. It is an amendment to the MVU pattern that I have used with great success in F#/Elmish.
Here is the MVU code.
This is literally my first Clojure program, so I am sure there are improvements to be made. Feedback or exposition of concepts is welcome.
(ns mvu
(:require [reagent.dom :as rdom]))
(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})))
(defn create [{init ::init
init-arg ::init-arg
_ ::update
render ::render
render-node ::render-node
perform ::perform
:as config}]
(let [[model effects] (init init-arg)
log (or (::log config) default-log)
state (merge {::log log}
config
{::model model
::history [[:init init-arg]]})]
(cond goog.DEBUG (log [:init] model effects))
(reset! state-atom state)
(rdom/render (render model) render-node)
(doseq [effect effects]
(perform model effect))))
(defn notify [& event]
(let [{updatef ::update
render ::render
render-node ::render-node
perform ::perform
log ::log
model ::model
history ::history} @state-atom
[model effects] (updatef model event)]
(cond goog.DEBUG (log event model effects))
(swap! state-atom merge
{::model model
::history (conj history event)})
(rdom/render (render model) render-node)
(doseq [effect effects]
(perform model effect))))
(defn on-reload [config]
(let [state (merge @state-atom config)
{render ::render
render-node ::render-node
model ::model
log ::log} state]
(cond goog.DEBUG (log [:hot-reload] model []))
(reset! state-atom state)
(rdom/render (render model) render-node)))
This code has debug logging and hot reload handling. It is also keeping event history for time-travel / repeatability purposes, but this is not used for now.
A few things tripped me up...
update
is already taken
At first I named my update function by the obvious name. My code seemed to work, but I kept getting kondo warnings or docstrings from the Clojure update
function. I am not very happy with calling it updatef
, but it works until I figure out a better name.
on-click delayed evaluation
I found the answer with a quick search. And it seems obvious now. At first I had :on-click (fn args)
which was immediately calling the function and trying to provide its value as an on-click handler. This results in an error. What I should have done was provide an anonymous function that, when invoked, would run the function. Clojure has a shortcut syntax for this: #(fn args)
.
hot reloading
This was giving me fits. First it was crashing with missing functions on hot reload. I wasn't helped by finding a shadow-cljs article stating "don't hold references to code" with no provided alternative. This became especially odd guidance once I thought about re-frame, which registers handler functions. So it has to be eschewing this suggestion too.
I figured out it was just errant atom usage. But then app changes were not showing up after hot reload. Eventually I realized that I had to have the app provide me a new config -- which includes all the MVU functions -- on reload. After all, the dev (me in this case) changed this code to trigger a hot reload. So the dev will have to provide it again, hence the on-reload
function in the app. I don't like requiring an extra function for hot reload, but as far as dev burden, it is ~1 line of code that will never change. So I guess it's fine.
starting too complicated
At first, I was trying to engineer for concurrency, queuing messages, running render/update/perform as separate "agents". (JS executes single-threaded, hence the air quotes, but it does have asynchrony.) This was too much when also being unfamiliar with the language. So I implemented a simple, synchronous loop to start.
TODO
None of these are important for my immediate needs, but I would like to make these adjustments for more general utility in the future.
asynchrony
This initial MVU loop operates synchronously. When you (mvu/notify :my-event)
that will run updatef
and save the new model, then rerender
the view from the new model, then perform
side effects. Some of these things will turn asynchronous and happen later, like React waiting for an animation frame, or your side effects waiting for IO results. But the MVU steps happen immediately.
This should be fine for my purposes (a completely offline PWA). But it will probably not work well in some cases. Example, high traffic web sockets. That case really needs batch processing. The batching story is surprisingly not great with core async because you potentially have to yield every time you try to pull from the channel. This is a minimum added latency for bursts. Using take n
or a batching transducer is potentially worse because it adds additionally waiting time (for a set amount of items or a timeout) before providing values. This is increased latency for sparse events. These are not the semantics that an MVU loop needs. For efficient batching it needs to grab up to N available messages off the channel at one time, which is not available functionality AFAICT. So I understand why re-frame implements its own queue processor. It looks like that might be in my future if I want to implement an asynchronous loop.
less expensive re-rendering
I am calling (reagent.dom/render ...)
every time the model changes. My unconfirmed fear is that this is doing a full virtual dom diff every time. But this was the only way I found to accomplish a re-render. I could not find a way to substitute new props in an existing component. (It wouldn't help anyway since the child components may change between updates.) And none of the MVU render functions will use a ratom. (It is kindof the point that MVU render does not use side-effecting reads.) Suggestions are welcome or maybe confirm for me that reagent.dom/render
is a good or bad way to rerender.
I will probably not dig into it myself, but I would really like to find a ClojureScript lib that takes Svelte's approach to updating the DOM for even better performance and memory footprint. Svelte has its own compiler to make it happen. I bet it could be done in ClojureScript much easier since code is data.
merging init/navigation into update
I have an idea that init could be just another event handled in the update
function. Same with navigation events. In typed languages this was not really possible because the input types for init
and navigation events differed from update. I need to do more thinking about this.
time travel / repro
It is conceptually easy to use events for repro or time travel or undo/redo. I think the main work is in the browser or plugin integration for import/export and visualization. I would have to think through semantics for undo/redo functionality. This is more of a nice-to-have, probably even demo-ware.
Top comments (0)