January 6, 2015

Lost in React: a logbook

I used the winter break as an opportunity to catch up with the latest changes in the Clojurescript world. I picked up Om again, got frustrated again, and looked for other resources again. But this time I wanted to write things down and understand where all the frustration and complexity where coming from and why. Here are my notes:

The Clojurescript + React playbook

What do we want? Efficient, reusable, simple components.

How can we achieve it? Some guidelines:

  1. A component should leverage the power of Cljs immutable data structures to avoid unnecessary renderings.
  2. A component should not depend on incidental information (e.g. its position in the dom) that would limit its reusability.
  3. A component should be able to communicate with other components.
  4. A component should be allowed to write and read temporary state (e.g. :editing? :animating?) like it owns that state.
  5. The component machinery should be simple and easy to understand.

How does Om meet these requirements?

  1. Deref'd cursors are values, hence fast equality checks
  2. Cursors allow a component to only see and manipulate its own subtree
  3. Core.async channels passed as state
  4. Mutable state encapsulated in each component
I find that Om works great except for 5). It's not a surprise many beginners like myself get confused when reifying protocols with mutable state, deref'ing cursors outside the rendering phase, transacting on the cursor with a path, getting the state of the owner, passing a map with shared state, etc. Om does a lot of things for you and it needs to be very opinionated.

(defn contact-view [contact owner]
    (render-state [this {:keys [delete]}]
      (dom/li nil
        (dom/span nil (display-name contact))
        (dom/button #js {:onClick (fn [e] (put! delete @contact))} "Delete")))))

example Om component

So what's on the other side of the spectrum?

Quiescent and Datascript: less is more

Quiescent is a wafer thin layer on top of React. Datascript is a Datomic-like in memory db for Clojurescript. Their powers joint together answer those the 5 points this way:

  1. Component functions take a single value and optional static arguments that are not involved in the equality check. The value must be fast to compare (more on that later).
  2. Datascript allows you to execute path-independent reads and writes.
  3. Core.async channels passed as static arguments.
  4. Quiescent does not allow encapsulated state by design (more on that later).

The resulting machinery is very easy to understand (if you're familiar with Datomic). Component functions deal with good old values. State is passed explicitly as extra arguments. Read and writes are performed Datomic style.

(defn contact-view [contact delete-chan]
 [:li [:span (:display-name contact)]
      [:button {:onClick (fn [e] (put! delete-chan contact))} "Delete"]])
example Quiescent component

All this works well unless you have a component that depends on the db value. The equality check on two dbs could be quite expensive. To avoid this, you can generate an appropriate data structure used by the components while rendering.

Every problem in CS can be solved by another layer of indirection

(except for the problem of too many layers of indirection)

Components should not depend on the db as a value. A part from slowing down equality check, it makes code harder to read, to test and to debug. You should give components exactly what they need, being it a movie, or a user or a list of messages. If you come from a server background you would recognize a similar pattern: fetch only the data you need from the db and structure it in a way that you can pass to your templates before sending it.

{:rooms [{:room ...
          :last-msg ...
          :unread ..}]
 :chat {:room-id ...
        :room-title ...
        :msgs ...}
 :compose {:user ...}}

example view data extracted from the excellent datascript-chat

The need for this layer of indirection is evident if you think of an app with more than one page: you are only interested in the models that participate in the current ui. MVC is a hard to die pattern for a reason. Having an intermediate view data structure opens many interesting possibilities (e.g. schema checks around components).

All state is application state

Quiescent forbids components to maintain local state. If you disagree with this idea you can simply copy/paste ~40 LOC and reimplement it yourself.

But if you like me agree with this philosophy you need a place to store this component-specific information. Friends don't let friends store ui state along with the models. On the other side our intermediate view data structure is an excellent candidate. From this follows:

  • The application state is the combination of model + view data.
  • A change in the model data should call a function that takes both the new model and the previous view data and builds a new view data.
  • A change in the view data should trigger a render pass.

In order to be able to manipulate its own state directly, the view data itself must be stored in a Datascript db. This way you have a unique identifier for each component that they may refer to if they want to update their state. And because Datascript, like Datomic, allow you to query from multiple different sources at once you can refer any model entity in the view schema instead of duplicating the information.

Before rendering, pull the entity with :widget/root? to rebuild the view tree.

{:db/id ...
 :widget/root?   true
 :widget/rooms   {:db/id ...
                  :widget/sticky? false
                  :widget/room [{:widget/selected? true
                                 :model/room ...
                                 :model/last-msg ...
                                 :model/unread ...}]}
 :widget/chat    {:db/id ...
                  :model/room ...
                  :model/msgs [...]}
 :widget/compose {:db/id ...
                  :widget/text ...
                  :model/user ...}}
persisted view data from the previous example

You can build this architecture with two atoms, one for the model and one for the view. But I think it's better to store everything in one atom having the view data as the value and the model data as its meta.

Tags: om quiescent datascript react