A macro I'm proud of
FYI: I imported this post from Substack, and the formatting is a little messed up.
I thought this week I’d simply share a little code a wrote somewhat recently. In the process I’ll share how I handle frontend state management currently. It’s not the fanciest solution out there, but it’s clean enough for Findka’s codebase (about 2.5K lines on the frontend at the moment).
First, I stopped storing everything in a single state atom a while ago. I used to do something like this:
(defonce state
(atom
{:foo "default value for foo"
:bar 1}))
(defonce foo (rum.core/cursor-in state [:foo]))
(defonce bar (rum.core/cursor-in state [:bar]))
but then decided that was silly, and switched to just
(defonce foo (atom "default value for foo"))
(defonce bar (atom 1))
As I am quite lazy, I wrote a macro to type out the defonce
s and the atom
s for me (I am fond of this simple macro, but it isn’t the macro I’m referring to in the subject):
(defmacro defatoms [& kvs]
`(do
~@(for [[k v] (partition 2 kvs)]
`(defonce ~k (atom ~v)))))
(defatoms
foo "default value for foo"
bar 1)
(I have a macro that does something similar for spec definitions too).
The part I really like is for derivations. Rum provides a derived-atom
function that works like Reagent’s reaction
. However, you have to specify the dependencies explicitly (and you have to provide a unique key for add-watch
):
(defonce baz (derived-atom [bar] ::baz
(fn [bar]
(+ bar 3))))
Since Reagent has magic for tracking dereferences, the equivalent is less verbose:
(defonce bar (reagent.core/atom 1))
(defonce baz (reagent.ratom/reaction (+ @bar 3)))
So, about the macro of which I am proud: I have written defderivations
which infers derivation dependencies based on the presence of @
:
(defderivations
baz (+ @bar 3)
...)
; Expands to:
(do
(defonce baz (derived-atom [bar] #uuid "..." ; random uuid
(fn [bar]
(+ bar 3))))
..)
Findka as of now has 22 source atoms and and 70 derivations. defderivations
aids readability quite a bit. As a small bonus, using @
as a dependency marker means you can evaluate (+ @bar 3)
via the repl and get the current value.
Biff’s example app currently uses an earlier version of defderivations
that’s less good (it also uses a single state atom… not a big deal since there’s only one cursor). I’ll switch it over to the new version at some point. I haven’t put the source on Github yet (publicly), but here it is, warts and all:
(require '[clojure.walk :refer [postwalk]])
; Adapted from postwalk source
(defn cardinality-many? [x]
(boolean
(some #(% x)
[list?
#(instance? clojure.lang.IMapEntry %)
seq?
#(instance? clojure.lang.IRecord %)
coll?])))
(defn postwalk-reduce [f acc x]
(reduce f
(if (cardinality-many? x)
(reduce (partial postwalk-reduce f) acc x)
acc)
[x]))
(defn deref-form? [x]
(and
(list? x)
(= 2 (count x))
; @ expands to ns-qualified deref
(= 'clojure.core/deref (first x))))
; I keep this in trident.util, but copied here for clarity
(defn pred-> [x f g]
(if (f x) (g x) x))
(defmacro defderivations [& kvs]
(do ~@(for [[sym form] (partition 2 kvs) :let [deps (->> form (postwalk-reduce (fn [deps x] (if (deref-form? x) (conj deps (second x)) deps)) []) distinct vec) form (postwalk #(pred-> % deref-form? second) form) k (java.util.UUID/randomUUID)]]
(defonce ~sym (rum.core/derived-atom ~deps ~k
(fn ~deps ~form))))))
(Why do I always use defonce
instead of just using that for source atoms? Early on I ran into a problem where redefining a derivation left the original still running—even when using a static add-watch
key, instead of a random UUID—causing performance to grind to a hault, since all the derivations get redefined whenever shadow-cljs evaluates the file again. defonce
was a quick fix, though sadly it means I have to hit refresh whenever I redefine a derivation. Some day I’ll figure out the root issue… eventually. As in, “probably never.”)
A caveat of this whole approach is that derivations are calculated depth-first. Suppose A depends on B and C which both depend on D. If D is updated, A might get updated twice: first with an updated value of B but an old value of C, and only after with updated values for both B and C. It’s caused subtle bugs for me once or twice. I think derivatives handles this correctly, though I haven’t read the source.
Published 1 Sep 2020