Someone probably had the idea before, but since I haven’t seen it posted I thought I’d share it here.
In summary: the idea is to parameterize generic components with Clojure Records, which combine both the “pure values” benefits of Clojure data (immutability, transparency, smart equality for caching) and the “behavioural expressiveness” qualities of callback functions.
These ideas also apply to re-frame, Rum, Quiescent, etc.
The problem: generic components want callbacks
For making highly reusable components (e.g a generic date-picker input), you want to give the caller high expressive power. Typically, this is done by parameterizing the component with callback functions: for example, in re-com, the datepicker
component accepts callback functions at keys :on-change
and :selectable-fn
.
One problem with that is that function-typed arguments aren’t very friendly to Reagent components. Reagent rewards calling components with immutable data structures that permit smart equality checks, to avoid re-rendering a component when its inputs haven’t changed. As soon as you call a Reagent component with a callback function (typically re-created by the caller at every render), you’re very likely to lose this benefit.
Compared to data structures, another downside of callbacks is that they’re opaque (e.g not practical to inspect at the REPL). Even if you’re able to decipher the origin of the callback from its name, you won’t typically be able to see the values it closes over, and so its behaviour will remain mysterious.
Proposal: callbacks as Clojure Records
I suggest tackling this problem by parameterizing the generic component not with callback functions, but with a Clojure Protocol, e.g:
(ns my.generic-components.date)
(defprotocol DatepickerCallbacks
(on-change [this new-value] "will be called when the user has picked a date.")
(selectable-date? [this d] "must return whether `d` is a valid date to pick."))
(defn datepicker
[opts callbacks current-date]
[:div
...
[:div
(for [d displayed-dates
:when (selectable-date? callbacks d)]
[:div {:on-click #(on-change callbacks d)}
...])]
...])
On the caller side, you define a Clojure Record implementing that protocol, and pass an instance of it to the generic component:
(ns my.specific.ui.order-form
(:require [re-frame.core :as rf]
[my.generic-components.date]
[cljs-time.core :as time]))
(defrecord DeliveryDatepickerCallbacks
[cart-id today-date]
my.generic-components.date/DatepickerCallbacks
(on-change [this new-delivery-date]
(rf/dispatch [:myapp.orders.events/set-order-delivery-date cart-id new-delivery-date]))
(selectable-date? [this d]
(time/after?
(time/floor d time/day)
today-date)))
(defn delivery-date-field
[order-form]
[:div
[:p "Please choose a delivery date:"]
[my.generic-components.date/datepicker {}
(->DeliveryDatepickerCallbacks
(:myapp.cart/id order-form)
(time/floor (time/now) time/day))
(:myapp.order-form/date order-form)]])
Benefits
The record instance is an easily-inspected value, can be easily manipulated for experimentation, and will benefit from the caching facilities of Reagent.
I also find that the (defprotocol ...)
expression is a convenient way to document the expected behaviour of the callbacks, so it can help with code clarity.
Drawbacks
One issue with the proposed approach is that you’re forced to define both a Protocol and a Record, and in particular you have to give them names. I think that’s not too much of an issue in this case, and if you really want an anonymous implementation at some point, there’s always reify
.
Another issue is partial implementation. A lot of the operations defined in the protocols might stem from optional customizability. Yet when the callee receives an instance of the protocol, it can’t know which operations have been left unimplemented by the caller; that means the caller will need to provide that operation elsewhere, e.g in an options map.
One pitfall of this approach is to let the Protocol grow too big and complect many aspects of the component. Don’t hesitate making several Protocols to avoid that.
Abstract thoughts
Let’s take a step back: in essence, how do we want to invoke our generic component?
- we prefer to use immutable, transparent data structures
- we want to customize our generic component by telling it how to perform various operations ; i.e, we want multi-operation polymorphism
It turns out that this is exactly what Clojure records are designed for ; that’s one reason to make me feel the approach is a natural fit for this problem.
Now, you might object that if this were such a good idea, people would be doing it already. Maybe. But I suspect that ClojureScript users don’t often think of using Records and Protocols: they’re a relatively advanced part of Clojure, and so far the ecosystem hasn’t given much exposure to these constructs. So maybe the opportunity has been missed simply for lack of attention.
What do you think?