Concise Om component composition

14.11.2014 Permalink

TL;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.