Concise Om component composition
14.11.2014 PermalinkTL;DR React facilitates the creation of reusable components for single-page browser applications. To be useful in a wide variety of contexts components are often configurable. If you find yourself creating numerous composite components with much configuration data you can easily jump to a higher abstraction level which yields code looking like this:
(defn home-page [state] (page "home" :path nil :elements [(textbox "name" :label "Full name") (checkbox "private" :label "Private?")]))
Forms over data
I have a very enterprise-y background. The style of UIs I encountered (and built) so far can be described by the term "forms-over-data": large number of forms composed out of a dozen of UI widget types, mostly static layout, visually a bit boring, but underneath full of complex validation logic and invariant checking. If you work in a user-centered development process1 for building the UI you'll usually see at some point in time sketches about how forms and their flow look like. You can get valid feedback with paper prototypes, but you learn more if users give feedback on the basis of software that kind-of already works. Building working prototypes is definitely more expensive than scribbling on paper so it should be the second step, but when you come to that task it would be great to be able to quickly translate the sketches 1:1 to code.
Om components
Let's switch over to Om2. Since it's based on React it promotes "componentization" of the UI, which means you'll likely create a toolbox of reusable components as you see recurring elements across UI sketches. Let's simulate this and build a form. I'll use a minimalistic example here, so I only introduce a configurable textbox and a checkbox:
(defn textbox [{:keys [value disabled message] :as state} owner {:keys [id label] :as opts}] (om/component (dom/div #js {:className "def-labeledwidget"} (let [input (dom/input #js {:id id :className "def-field" :type "text" :value (or value "") :disabled disabled :ref id :onChange #(om/update! state :value (-> % .-target .-value))})] (if label (dom/label nil (dom/span #js {:className "def-label"} label) input) input)) (if message (dom/span #js {:className "error-message"} message))))) (defn checkbox [{:keys [value disabled message] :as state} owner {:keys [id label] :as opts}] (om/component (dom/div #js {:className "def-labeledwidget"} (let [input (dom/input #js {:id id :className "def-checkbox" :type "checkbox" :disabled disabled :checked value :onChange #(om/update! state :value (-> % .-target .-checked))})] (if label (dom/label nil (dom/span #js {:className "def-label"} label) input) input)) (if message (dom/span #js {:className "error-message"} message)))))
Please note that the components receive static configuration
data via the third function argument bound to opts
.
Apparently, I can refactor the component implementation to extract the label
decoration by introducing a decorate
function:
(defn- decorate ([label content] (decorate label nil content)) ([label message content] (dom/div #js {:className "def-labeledwidget"} (if label (dom/label nil (dom/span #js {:className "def-label"} label) content) content) (if message (dom/span #js {:className "error-message"} message))))) (defn textbox [{:keys [value disabled message] :as state} owner {:keys [id label] :as opts}] (om/component (decorate label message (dom/input #js {:id id :className "def-field" :type "text" :value (or value "") :disabled disabled :ref id :onChange #(om/update! state :value (-> % .-target .-value))})))) (defn checkbox [{:keys [value disabled message] :as state} owner {:keys [id label] :as opts}] (om/component (decorate label message (dom/input #js {:id id :className "def-checkbox" :type "checkbox" :disabled disabled :checked value :onChange #(om/update! state :value (-> % .-target .-checked))}))))
Nice, the rendering code as such can be made more modular by
splitting it up into functions, no component ceremony needed for
this. However, thanks to HTML these are still quite some lines
of code for two little widgets3,
but since the components are reusable I don't mind. Finally,
here's a page component and the obligatory call to om/root
:
(defn page [state owner] (om/component (dom/div nil (om/build textbox (:name state) {:opts {:id "name" :label "Full name"}}) (om/build checkbox (:private state) {:opts {:id "private" :label "Private?"}})))) (om/root page app-state {:target (. js/document (getElementById "app"))})
That's it, two configurable components and one composite page using them, everything is fine.
The rise of data
Let's assume now we have a sufficient number of components in
our toolbox and someone asks us to create a prototype based on
a pile of UI sketches. We might just proceed by creating more
composite components in the style of our page
, but let's
first take a closer look to how it's implemented (please see above).
The code within page
smells a bit like
boilerplate. Essentially our page consists of components of
different types, each with individual configuration. The
configuration is passed as map via the :opts
entry. The type
is currently encoded as component function, passed as first
parameter to om/build
. If we instead encode
the type in a keyword we can treat it as part of the
configuration. This seemingly tiny step allows us to describe
a page solely in terms of data.
Data has the nice property that it promotes concentration on what, independent of how or where it is used later on. Data can be designed in terms of the domain we want to express (here, the domain is the visual design of UI), allowing us to have a representation independent of technical API access. I call this data a specification4, or spec for short. Since React components already relate nicely to the bits we see in UI sketches the spec needs to consist only of custom configuration, the path into the state and the type of component.
There is one more benefit we can attain. Look at
how the checkbox invocation is defined: the path keyword :private
we use to create the cursor is just a keyword version of the
id "private"
, which is redundant in this
case. This congruence is quite likely, so it's reasonable to
have a default path for navigating into the application state
derived from the id5.
If we create specs by invoking factory
functions instead of just writing out the map literal we have
a perfect place to encode these defaults.
Let's create some neat spec factory functions:
(defn textbox [id & {:keys [path label] :or {path [(keyword id)] label (clojure.string/capitalize id)}}] {::type ::textbox :id id :path path :label label}) (defn checkbox [id & {:keys [path label] :or {path [(keyword id)] label (clojure.string/capitalize id)}}] {::type ::checkbox :id id :path path :label label}) (defn page [id & {:keys [path elements] :or {path [(keyword id)]}}] {::type ::page :elements elements :path path})
There's really not much to these factory functions, perhaps
the most interesting part is the :or
in the map
destructuring to derive the default values.
To be able to build components according to specs I introduce
a component
multimethod that expects a specification in the
opts
map and dispatches over the ::type
value. This
brings uniformity to the way components can be configured and built.
(defmulti component (fn [_ _ opts] (-> opts :spec ::type))) (defn build [state {:keys [id path type] :as spec}] (om/build component (if path (get-in state path) state) {:react-key id :opts {:spec spec}}))
I have to slightly change the existing components to become methods that expect specs:
(defmethod component ::textbox [{:keys [value disabled message] :as state} owner {{:keys [id label]} :spec}] (om/component (decorate label message (dom/input #js {:id id :className "def-field" :type "text" :value (or value "") :disabled disabled :ref id :onChange #(om/update! state :value (-> % .-target .-value))})))) (defmethod component ::checkbox [{:keys [value disabled message] :as state} owner {{:keys [id label]} :spec}] (om/component (decorate label message (dom/input #js {:id id :className "def-checkbox" :type "checkbox" :disabled disabled :checked value :onChange #(om/update! state :value (-> % .-target .-checked))})))) (defmethod component ::page [state owner {{:keys [elements]} :spec}] (om/component (apply (partial dom/div nil) (for [s elements] (build state s)))))
The result is that I can build any such component by
evaluating (build state spec)
.
Ok, done with the necessary changes. Now we're ready to make use of them:
(defn home-page [state] (page "home" :path nil :elements [(textbox "name" :label "Full name") (checkbox "private" :label "Private?")])) (om/root component app-state {:target (. js/document (getElementById "app")) :opts {:spec (home-page app-state)}})
Interesting! Look at the home-page
function6. Without
thinking too much about how to get rid of noisy HTML we just
arrived at a more meaningful level of expressing the static
aspects of a UI.
There's a nice 1:1 relationship between a thing sketched on a
piece of paper and a formal spec expression. I admit, as it is
right now there's no way to specify layout information. But
you can add this easily as CSS classname property for
page
or express it by having different types of
container components.
The code resulting from transformation of numerous sketches to specs will now be quite readable and changeable. In fact, the spec factories form an internal DSL. I imagine one could even sit down with their user and live code, err... sorry, specify the UI interactively.
Conclusion
It's just a tiny idea and a trivial implementation that opens the door to make UI code more maintainable. But don't rush into it as this approach pays off only when you have some number of composite components that combine existing components. However, this is a typical characteristic for enterprise forms-over-data UIs, so I consider the idea to be valuable in this context.
I currently explore this and other ideas on a GitHub project named zackzack.
1. If you don't use steady user feedback to design your UI you run the risk of building the wrong application: one that doesn't fit user needs. This is the most expensive type of mistake you can make in your software project. 2. What I do here can likely be transferred to other React wrapping libraries like Reagent, Quiescent or Reacl. 3. I'm aware of the existence of libraries like om-tools, or kioo and sablono to ease this pain. 4. Instead of "specification" I could have used the term "model", but most people associate models with something graphical, so the latter term might cause confusion. 5. The underlying design principle is Convention over Configuration. 6. Defining the
home-page
spec within a function
instead of just a def
has the benefit to create
state dependent specs, and it's also more friendly for REPL-based
ClojureScript development.