Pages

Reagent deep dive part 2: The lifecycle of a component



(ns my.reagent-examples
  (:require
    [reagent.core :as reagent]
    [reagent.dom]
    [reagent.ratom]))

(enable-console-print!)

Welcome to the second leg of the Reagent deep dive tour!

In part 1 we saw how to create components and react to change. Now we move on to some more challenging aspects of UI building. Today we examine how to represent component local state and how to access the rendered DOM nodes. We will need these capabilities in order to create some more interesting components and make use of third party JavaScript libraries so that we can render a 3D scene.




Component forms

One of the basic things about a string is that it can vibrate in many different shapes or forms, which gives music its beauty. -- Edward Witten


We have a mechanism for change, which we examined in part 1, and it works great for everything we can define in Reagent that has access to an external reagent/atom. But not everything falls into this neat view of the world. There are two exceptions:
  1. What if we want a self-contained component with it's own state? We want to be able to define and retain a local reagent/atom instead of relying on one from the surrounding environment in order to build reusable components.
  2. What if we want a component to call JavaScript functions on to the actual HTML elements after they are rendered? There are many great JavaScript libraries that operate directly on HTML elements, and in order to use them we need to be able to invoke them after the component has created the elements.
The Reagent answer comes in two additional forms of specifying what a component is. So far we have been using the first form, a function that returns hiccup. In total there are 3 important forms for specifying components:
  1. A function that returns a hiccup vector.
  2. A function that returns a function.
  3. A function that returns a Class.


Moodswingerscale


We saw plenty of examples of form 1 in part 1 of the deep dive tour. All the examples were functions that returned hiccup. So let's examine form 2 more closely now.


Example K: Reagent component form 2 - A function that returns a function


(defn greetings []
  (fn []
    [:h3 "Hello world"]))

Here is a function that returns a function. The returned function (the inner function) returns a hiccup vector representing HTML. The outer function just returns the inner function.

Exercise: Is a function that returns a function that returns a function a valid Reagent component? Find out by modifying the examples above. Wrap the inner function in yet another function.

Form 2 is useful for performing initial setup for a component. A common usage of this form is to establish some local state. Consider this example which creates a reagent/atom counter per instance:

Example L: Reagent component form 2 - A function that returns a function


(defn a-better-mouse-trap [mouse]
  (let [mice (reagent/atom 1)]
    (fn render-mouse-trap [mouse]
      (into
        [:div
         [:button
          {:on-click
           (fn [e]
             (swap! mice (fn [m] (inc (mod m 4)))))}
          "Catch!"]]
        (repeat @mice mouse)))))

[:div
 [a-better-mouse-trap
  [:img
   {:src "https://www.domyownpestcontrol.com/images/content/mouse.jpg"
    :style {:width "150px" :border "1px solid"}}]]
 [a-better-mouse-trap
  [:img
   {:src "https://avatars1.githubusercontent.com/u/9254615?v=3&s=150"
    :style {:border "1px solid"}}]]]

These mice traps each have their own count of mice per trap. Compare this example to the previous counter in part 1 example H, which relied on a single global count. Global state, and state passed as arguments tend to be useful for application features. Local state tends to be useful for self contained components.

Notice that this example is really just a closure (variable capture) occurring inside a function.

Seeing that this is a common pattern, Reagent also provides with-let which will take care of the inner function for you:

Example M: Using with-let to avoid returning a function


(defn lambda [rotation x y]
  [:g {:transform (str "translate(" x "," y ")"
                       "rotate(" rotation ") ")}
   [:circle {:r 50, :fill "green"}]
   [:circle {:r 25, :fill "blue"}]
   [:path {:stroke-width 12
           :stroke "white"
           :fill "none"
           :d "M -45,-35 C 25,-35 -25,35 45,35 M 0,0 -45,45"}]])

(defn spinnable []
  (reagent/with-let [rotation (reagent/atom 0)]
    [:svg
     {:width 150 :height 150
      :on-mouse-move
      (fn [e]
        (swap! rotation + 30))}
     [lambda @rotation 75 75]]))

(defn several-spinnables []
  [:div
   [:h3 "Move your mouse over me"]
   [a-better-mouse-trap [spinnable]]])


This is a slightly more compact way of expressing the same concept. The rotation atom is created only once, while the component will be re-rendered when the rotation value is modified.


O.K. so what about form 3? Let's look at how to create a Class:

Example N: Reagent component form 3 - A function that returns a Class


(defn announcement []
  (reagent/create-class
    {:reagent-render
     (fn []
       [:h3 "I for one welcome our new insect overlords."])}))





This code should look familiar in that the reagent-render function is exactly like any other component function we have seen before. It has been wrapped explicitly in a create-class call. The only difference is that we can also specify other lifecycle functions, which we will make use of soon. 

A React Class lifecycle consists of:

Mounting (Occurs once when the component is created)

  • constructor
  • componentWillMount
  • render
  • componentDidMount

Updating (Occurs many times as the component reacts to change)

  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate
  • render
  • componentDidUpdate

Unmounting (Occurs once when the component will be removed from the DOM)

  • componentWillUnmount
You can read more about the component lifecycle in the React docs.

Fortunately we can take a much simpler view of the world in Reagent! The two lifecycle functions that can be of additional use to us are the componentDidMount function, which will allow us to interact with the created DOM elements, and the componentWillUnmount which will allow us to do cleanup.

Before we go much further into what create-class allows us to do, let's take a brief interlude to examine a case where it looks like we need to use a class, but in fact we can avoid using one.

Example O: Performing cleanup


(defn mouse-position []
  (reagent/with-let [pointer (reagent/atom nil)
                     handler (fn [e]
                               (swap! pointer assoc
                                 :x (.-pageX e)
                                 :y (.-pageY e)))
                     _ (js/document.addEventListener "mousemove" handler)]
    [:div "Pointer moved to: " (str @pointer)]
    (finally
      (js/document.removeEventListener "mousemove" handler))))

The finally clause of with-let will run when mouse-pos is no longer tracked anywhere, in this case when tracked-pos is unmounted. The same thing could be achieved with a Class that specified a component-will-unmount.

Reagent has well thought out facilities that allow us to write our components as simple functions that respond to change. Knowing the model of how those function relate to the underlying React lifecycle is useful for reasoning about component behaviors and being able to choose concise functions as much as possible. The bottom of the abstraction is creating the Class directly. Why do we still need the ability to create a Class?





Well... one case is that when we need to access the DOM node of the component we constructed. This comes up when making use of non-React JavaScript UI libraries. For instance if you want to use Google Charts; you need to call a render function on a target element after it is created. This is where the the component-did-mount lifecycle method becomes valuable.

Tip: You can alternatively provide a ref function as an attribute to a hiccup form to access DOM nodes. React will call the ref callback with the DOM element when the component mounts, and call it with null when it unmounts. ref callbacks are invoked before componentDidMount or componentDidUpdate lifecycle hooks. For now we'll focus on using the class lifecycle.

Let's see how to make use of form 3 by creating a ThreeJS canvas.

Example P: Reagent component form 3 - Creating a ThreeJS canvas


(defn create-renderer [element]
  (doto (js/THREE.WebGLRenderer. #js {:canvas element :antialias true})
    (.setPixelRatio js/window.devicePixelRatio)))

(defn three-canvas [attributes camera scene tick]
  (let [requested-animation (atom nil)]
    (reagent/create-class
      {:display-name "three-canvas"
       :reagent-render
       (fn three-canvas-render []
         [:canvas attributes])
       :component-did-mount
       (fn three-canvas-did-mount [this]
         (let [e (reagent.dom/dom-node this)
               r (create-renderer e)]
           ((fn animate []
              (tick)
              (.render r scene camera)
              (reset! requested-animation (js/window.requestAnimationFrame animate))))))
       :component-will-unmount
       (fn [this]
         (js/window.cancelAnimationFrame @requested-animation))})))

This is a more involved example to show the use of a non-React JavaScript UI library. We are using the ThreeJS library to render a scene. The important thing to look for in this example code is the use of lifecycle methods; reagent-render is a component function that returns hiccup HTML, component-did-mount is called when the element is mounted into the page, and component-will-unmount is called just before the element leaves the page.

There are 2 interop tasks we do in our ThreeJS component:
  1. We start a request animation frame loop to render the scene. But we are careful to stop the animation loop when the component is unmounted. This will allow our component to play nicely with our page if we add and remove it.
  2. We create a renderer that targets the DOM node after it is mounted into the page.
Ok great, but where's our scene? We need to construct some lights and objects to see anything interesting. Let's make a 3D version of the concentric circles we made in SVG earlier.

Example Q: A ThreeJS version of concentric circles


(defn create-scene []
  (doto (js/THREE.Scene.)
    (.add (js/THREE.AmbientLight. 0x888888))
    (.add (doto (js/THREE.DirectionalLight. 0xffff88 0.5)
            (-> (.-position) (.set -600 300 600))))
    (.add (js/THREE.AxisHelper. 50))))

(defn mesh [geometry color]
  (js/THREE.SceneUtils.createMultiMaterialObject.
    geometry
    #js [(js/THREE.MeshBasicMaterial. #js {:color color :wireframe true})
         (js/THREE.MeshLambertMaterial. #js {:color color})]))

(defn fly-around-z-axis [camera scene]
  (let [t (* (js/Date.now) 0.0002)]
    (doto camera
      (-> (.-position) (.set (* 100 (js/Math.cos t)) (* 100 (js/Math.sin t)) 100))
      (.lookAt (.-position scene)))))

(defn v3 [x y z]
  (js/THREE.Vector3. x y z))

(defn lambda-3d []
  (let [camera (js/THREE.PerspectiveCamera. 45 1 1 2000)
        curve (js/THREE.CubicBezierCurve3.
                (v3 -30 -30 10)
                (v3 0 -30 10)
                (v3 0 30 10)
                (v3 30 30 10))
        path-geometry (js/THREE.TubeGeometry. curve 20 4 8 false)
        scene (doto (create-scene)
                (.add
                  (doto (mesh (js/THREE.CylinderGeometry. 40 40 5 24) "green")
                    (-> (.-rotation) (.set (/ js/Math.PI 2) 0 0))))
                (.add
                  (doto (mesh (js/THREE.CylinderGeometry. 20 20 10 24) "blue")
                    (-> (.-rotation) (.set (/ js/Math.PI 2) 0 0))))
                (.add (mesh path-geometry "white")))
         tick (fn []
                (fly-around-z-axis camera scene))]
    [three-canvas {:width 150 :height 150} camera scene tick]))
    
(defn lambda-3d-counter []
  [a-better-mouse-trap [lambda-3d]])

Tada! We have a 3D scene.


Exercise: Add some more meshes to the scene. Complete the Lambda symbol (λ) by adding a diagonal down mesh.

What I find really neat is that this 3D scene composes well with our existing components, here it is inside the mouse trap:
With attention to the component lifecycle we were able to make use a library that was not designed with React or Reagent in mind. We didn't need a complicated wrapper; creating a class was the easy bit. Most of our effort was specifying the scene itself.

Seeing as we created a 3D scene, let's make use of it to draw something else. A Sierpinski 3D gasket is a recursively defined object with volume that approaches zero each step, while the surface area remains constant. That's pretty weird huh?

Example S: Sierpinski Gasket in 3D


(def pyramid-points
  [[-0.5 -0.5 0 "#63B132"] [-0.5 0.5 0 "#5881D8"] [0.5 0.5 0 "#90B4FE"] [0.5 -0.5 0 "#91DC47"] [0 0 1 "white"]])

(defn add-pyramid [scene x y z size color]
  (.add scene
        (doto
          (let [g (js/THREE.Geometry.)]
            (set! (.-vertices g)
                  (clj->js (for [[i j k] pyramid-points]
                             (v3 i j k))))
            (set! (.-faces g)
                  (clj->js (for [[i j k] [[0 1 2] [0 2 3] [1 0 4] [2 1 4] [3 2 4] [0 3 4]]]
                             (js/THREE.Face3. i j k))))
            (mesh g color))
          (-> (.-position) (.set x y z))
          (-> (.-scale) (.set size size size)))))

(defn add-pyramids [scene x y z size color]
  (if (< size 4)
    (add-pyramid scene x y z (* size 1.75) color)
    (doseq [[i j k color] pyramid-points]
      (add-pyramids scene
                    (+ x (* i size))
                    (+ y (* j size))
                    (+ z (* k size))
                    (/ size 2)
                    color))))

(defn gasket-3d []
  (let [camera (js/THREE.PerspectiveCamera. 45 1 1 2000)
        scene (doto (create-scene)
                (add-pyramids 0 0 0 32 "white"))
        tick (fn [] (fly-around-z-axis camera scene))]
    [three-canvas {:width 640 :height 640} camera scene tick]))

Suggested soundtrack for appreciating the Sierpinski gasket:





Exercise: Can you make a tetrahedral gasket by modifying the points and faces list? (Hint: you just need to delete one of the base points to make a triangular prism.) How about a cubic gasket?

ClojureScript really shines in it's facilities for avoiding repetitive boilerplate.


Lifecycle Review

Twice and thrice over, as they say, good is it to repeat and review what is good. -- Plato




Most Reagent components can be expressed as a function, especially if they rely on state being passed to them as an argument. Some components will create local state, access the DOM node, or need to do some setup/teardown. All components have a lifecycle. They get created, mounted into the DOM, rendered, and unmounted from the DOM. A component potentially calls render many times as it's inputs change. It remains in the DOM until unmounted.

In order to show the lifecycle in action, let's log what's happening in a form 2 component (a function that returns a function).

(def messages (reagent/atom []))

(defn log [& args]
  (apply cljs.core/println args)
  (swap! messages
         (fn [xs]
           (doall (take 10 (cons (apply str (.toLocaleTimeString (js/Date.)) "| " args) xs))))))

(defn with-log [component]
  [:div
   component
   (into
     [:ul]
     (for [line @messages]
       [:li line]))])

Example T: Observing the lifecycle of a puppy


(defn puppy [x]
  (log "puppy created, x:" x)
  (let [mouse-over? (reagent/atom false)]
    (fn [y]
      (log "puppy rendered, x:" x " y:" y " mouse-over?:" @mouse-over?)
      [:span {:on-mouse-over (fn [e] (reset! mouse-over? true))
              :on-mouse-out (fn [e] (reset! mouse-over? false))}
       [:img {:src "https://i.pinimg.com/originals/dc/c1/7e/dcc17eda3b4e65e30c39c1bd1fd58abb.png"
              :style {:width "150px",
                      :border "1px solid",
                      :transform (str "scale(" (if @mouse-over? 1.1 1) ")")}}]])))

(defn lifecycle-review []
  (reagent/with-let [x (reagent/atom "1")]
    [:div
     [:label "Type in a value for x: "
      [:input {:on-change (fn [e] (reset! x (.. e -target -value)))}]]
     [with-log [a-better-mouse-trap [puppy @x]]]]))

Pop quiz: Enter a string in the text box above and mouse over the puppy. You should see that x and y do not match. Why are they different? Now click "catch" to create a new puppy. See that x now has the new value when you mouse over the new puppy, but x still has the old value when you mouse over the old puppy. Can you explain why?

As you can see by playing with this example, puppy is called once at creation, but the function it returns is called whenever you mouse over the puppy. One trap to avoid is forgetting to specify the inner function arguments, or giving them a different name. I intentionally gave them different names in the above example to demonstrate that x is being captured from the outer function. The captured value won't change! However this is easily avoided if you keep the inner function arguments identical to the outer arguments. If the arguments are identical, the inner function will not capture any of the outer bindings.


Conclusion

The pain of parting is nothing to the joy of meeting again. -- Charles Dickens


We have reached the second stop of our deep dive tour.

At this point we have covered the principal syntax and features of Reagent. We observed a variety of UI challenges and the forms Reagent provides to address them. Reagent's fundamental abstraction is a view component. A function that returns HTML as hiccup is a component. A function that returns a function is a component. A function that returns a Class is a component. These three forms allow us to manage lifecycle concerns such as state and DOM node interaction.

Editing code in the browser itself is a great way to try out Reagent. If you want to build some larger ideas you might find KLIPSE useful. If you enjoy interactive tutorials, make sure you check out the excellent articles in the KLIPSE blog.

In part 3 of our tour we will examine the nuances of dealing with sequences of subcomponents. I hope you can join me again for that soon!