Reusable Om components, part 2

December 7, 2014

In part 1 of this series we built a declarative select component. In this post we will instead build a table component. We take the same approach but along the way we will find a few interesting limitations and gotchas.

Let's start the same way as in the previous installment, but this time we'll use a different data set:

(def posts
  [{:project "Reagent"
    :author "holmsand"
    :url "https://github.com/reagent-project/reagent"}
   {:project "Quiescent"
    :author "levand"
    :url "https://github.com/levand/quiescent"}
   {:project "Reacl"
    :author "active-group"
    :url "https://github.com/active-group/reacl"}
   {:project "Om"
    :author "swannodette"
    :url "https://github.com/swannodette/om"}])

The table component we'll build will be used as follows:

(om/build table
          {:data projects
           :columns [{:title "Author"
                      :cell-fn :author}
                     {:title "Project"
                      :cell-fn (fn [{:keys [project url]
                                 (dom/a #js {:href url} project)}


I hope the above is self-explanatory (at least that's the goal). In particular, the :columns vector is a declerative description of the table columns. The cell-fn takes a record and returns something that should be rendered. For "Author" the cell-fn simply returns a string and for the "Project" an anchor tag is returned.

The table component as described above will work fine until you need to render another component in the cells of a column. For example, say you have created a component voter which you would like to use to vote on the individual projects:

(defn voter [{:keys [votes on-vote]} owner]
  (om/component
   (dom/span nil
             (dom/button #js {:onClick #(on-vote (inc votes))} "+")
             votes
             (dom/button #js {:onClick #(on-vote (dec votes))} "-"))))

The voter function is an Om component and we should be able to render it in our table. Let's extend the :columns description with an :cell key:

(om/build table
          {:data projects
           :columns [{:title "Votes"
                      :cell voter
                      :cell-data-fn (fn [record]
                                      {:votes <number-of-votes>
                                       :on-vote <on-vote-callback>})
                     {:title "Author"
                      :cell-fn :author}
                     {:title "Project"
                      :cell-fn (fn [{:keys [project url]
                                 (dom/a #js {:href url} project)}

Note that we also need a :cell-data-fn key that, given a record returns the data that the cell component needs. In the implementation you will see that :cell-data-fn defaults to identity since it's often the case that the component only needs the actual record.

All the things that won't work

For me, the biggest limitation of Om is the lack of support for anonymous and higher order components. For example, it would be great if we wouldn't need both :cell and :cell-fn and for simple cases would be able to write

(om/build table
          {:data projects
           :columns [{:title "Project"
                      :cell #(om/component (dom/a #js {:href (:url %)} (:name %)))}
                     ...]})

The above will appear to work, but it does not do what you might expect. A new component (for each record in this case) is created and mounted each time the parent component is rendered! The same story again with higher order components:

(defn text-component [key]
  (fn [data _]
    (om/component
      (span nil (key data)))))

(om/build table
          {:data projects
           :columns [{:title "Author"
                      :cell (text-component :author)}
                     ...]})

The same problem appears here. A new component is created and mounted on each re-render. It might appear to work fine until, for example, you start to loose focus on input elements or experience performance problems.

I'd love to hear from the other Clojurescript React wrappers out there (reagent, quiescent etc.) if they have found any solutions for these problems. It's my understanding however that React itself has this same limitation and is therefor a fundamental problem which is difficult to get away from (I would be extremely happy to be proven wrong on this issue!). Unfortunately, this means that one of the most powerful abstraction mechanisms in functional programming (= closures) are not available to component authors.

Example application

Here's a very simple example application using (and implementing) the table component which sorts the table based on the votes:

;; table component

(defn table-header [columns owner]
  (om/component
   (dom/thead nil
              (apply dom/tr nil
                     (map #(dom/th nil (:title %))
                          columns)))))

(defn table-body [{:keys [data columns]} owner]
  (om/component
   (apply dom/tbody nil
     (for [record data]
       (apply dom/tr nil
         (for [{:keys [cell-fn cell cell-data-fn]} columns]
           (dom/td nil
             (if cell-fn
               (cell-fn record)
               (om/build cell ((or cell-data-fn identity) record))))))))))

(defn table [table-spec owner]
  (om/component
   (dom/table nil
              (om/build table-header (:columns table-spec))
              (om/build table-body table-spec))))


;;; Application

(defn index-by [f coll]
  (reduce (fn [result item]
            (assoc result (f item) item))
          {}
          coll))

(def app-state (atom (index-by :name projects)))

(defn voter [{:keys [votes on-vote]} owner]
  (om/component
   (dom/span nil
             (dom/button #js {:onClick #(on-vote (inc votes))} "+")
             votes
             (dom/button #js {:onClick #(on-vote (dec votes))} "-"))))

(defn root-component [data]
  (om/component
   (let [data (om/value data)]
     (om/build table
               {:data (->> data
                           om/value
                           vals
                           (sort-by #(or (:votes %) 0))
                           reverse)
                :columns [{:title "Votes"
                           :cell voter
                           :cell-data-fn (fn [record]
                                           {:votes (or (:votes record) 0)
                                            :on-vote (fn [n]
                                                       (swap! app-state
                                                              assoc-in
                                                              [(:name record) :votes]
                                                              n))})}
                          {:title "Author"
                           :cell-fn :author}
                          {:title "Project"
                           :cell-fn (fn [{:keys [name url]}]
                                      (dom/a #js {:href url} name))}]}))))

(defn init []
  (om/root root-component
           app-state
           {:target (.getElementById js/document "app")}))

If you want to run the application you can clone the nil-recur repo where you can find this example.