Jacob O'Bryant
Home · Archive
A macro I'm proud of
2 September 2020

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 defonces and the atoms 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.

I don't write this newsletter anymore but am too lazy to disable this signup form. Subscribe over at tfos.co instead.