11. Reagent part 3: Keys & Lifecycle Methods

Freebie

Published 17 July 16

Learn about React performance, the use of keys properties, and how to use lifecycle methods with Reagent.

browse source code

To get the code at the start of the episode so you can follow along

git clone https://github.com/lambdaisland/kanban.git
cd kanban
git checkout ep-10-reagent-cursors

To try out the fully finished application

git checkout ep-11-keys-lifecycle

Here’s the fully finished application code

(ns kanban.core
  (:require [reagent.core :as r]))

(enable-console-print!)

(def app-state
  (r/atom {:columns [{:id (random-uuid)
                      :title "Todos"
                      :cards [{:id (random-uuid)
                               :title "Learn about Reagent"}
                              {:id (random-uuid)
                               :title "Tell my friends about Lambda Island"}]}
                     {:id (random-uuid)
                      :title "Awesomize"
                      :cards [{:id (random-uuid)
                               :title "Meditate"}
                              {:id (random-uuid)
                               :title "Work out"}]}]}))

(defn AutoFocusInput [props]
  (r/create-class {:displayName "AutoFocusInput"
                   :component-did-mount (fn [component]
                                          (.focus (r/dom-node component)))
                   :reagent-render (fn [props]
                                     [:input props])}))

(defn- update-title [cursor title]
  (swap! cursor assoc :title title))

(defn- stop-editing [cursor]
  (swap! cursor dissoc :editing))

(defn- start-editing [cursor]
  (swap! cursor assoc :editing true))

(defn Editable [el cursor]
  (let [{:keys [editing title]} @cursor]
    (if editing
      [el {:className "editing"} [AutoFocusInput {:type "text"
                                                  :value title
                                                  :on-change #(update-title cursor (.. % -target -value))
                                                  :on-blur #(stop-editing cursor)
                                                  :on-key-press #(if (= (.-charCode %) 13)
                                                                   (stop-editing cursor))}]]
      [el {:on-click #(start-editing cursor)} title])))

(defn Card [cursor]
  [Editable :div.card cursor])

(defn add-new-card [col-cur]
  (swap! col-cur update :cards conj {:id (random-uuid)
                                     :title ""
                                     :editing true}))

(defn NewCard [col-cur]
  [:div.new-card
   {:on-click #(add-new-card col-cur)}
   "+ add new card"])

(defn Column [col-cur]
  (let [{:keys [title cards editing]} @col-cur]
    [:div.column
     ^{:key "title"} [Editable :h2 col-cur]
     (map-indexed (fn [idx {id :id}]
                    (let [card-cur (r/cursor col-cur [:cards idx])]
                      ^{:key id} [Card card-cur]))
                  cards)
     ^{:key "new"} [NewCard col-cur]]))

(defn- add-new-column [board]
  (swap! board update :columns conj {:id (random-uuid)
                                     :title ""
                                     :cards []
                                     :editing true}))

(defn NewColumn [board]
  [:div.new-column
   {:on-click #(add-new-column board)}
   "+ add new column"])

(defn Board [board]
  [:div.board
   (map-indexed (fn [idx {id :id}]
                  (let [col-cur (r/cursor board [:columns idx])]
                    ^{:key id} [Column col-cur]))
                (:columns @board))
   ^{:key "new"} [NewColumn board]])

(r/render [Board app-state] (js/document.getElementById "app"))

Lifecycle Methods

To learn more about React’s lifecycle methods, The official React docs on Component Specs and Lifecycle Methods are a great start.

In previous episodes we started building a Kanban board with cards organized in columns. In this episode we’ll provide the finishing touches, you’ll learn about element keys and why they matter for performance, and also how to use React’s lifecycle methods like component-did-mount with Reagent.

As usual all the code is on Github, so if you didn’t follow along with the previous episode just grab it from there and check out the right branch. You can the link and instructions in the show notes.

Now open the app and have a look at the browser console. Both React and Reagent are trying to warn us about something. When there are multiple columns on a board, or multiple cards in a column, then React wants you to give each of these a “key” property. So let’s do that.

Keys don’t need to be globally unique, they just need to be unique among siblings. Since there will only ever be one title and one “new card” button in a column, I can simply give these a hard coded key. For the individual cards I’m using the card’s position as key.

The Card and NewCard now take a properties map as a first argument, so we need to update their signatures.

(defn Card [props col-cur] ,,,)
(defn NewCard [props] ,,,)

[:div.column
 (if editing
   [:input {:type "text" :value title :key "edit"}]
   [:h2 {:key "title"} title])
 (for [i (range (count cards))]
   [Card {:key i} (r/cursor col-cur [:cards i])])
 [NewCard {:key "new"}]]

Maybe you’re already using property maps to pass data to components, as you would in React, in which case this approach is fine. But Reagent also lets you use positional arguments, which is what we’ve been doing so far.

So if you prefer not to have the properties map, then you can also specify the key as metadata. Remember that Clojure has a facility to attach extra metadata to objects using this caret notation.

Let’s also do the same for columns in a board. Now we’ve added keys everywhere where there are multiple children with the same parent. Reload the browser, and the warnings should be gone.

(defn Column [col-cur]
  (let [{:keys [title cards editing]} @col-cur]
    [:div.column
     (if editing
       [:input {:type "text" :value title :key "edit"}]
       [:h2 {:key "title"} title])
     (for [i (range (count cards))]
       ^{:key i} [Card (r/cursor col-cur [:cards i])])
     ^{:key "new"} [NewCard]]))


(defn Board [board]
  [:div.board
   (for [i (range (count (:columns @board)))]
     ^{:key i} [Column (r/cursor state [:columns i])])
   ^{:key "new"} [NewColumn]])

These keys are really just a performance hint for React. When doing an update React compares the old version of the DOM with the new version, and figures out what needs to change.

Each list of child elements is scanned from left to right, and compared with the corresponding element in the previous DOM. If we insert a new element at the start of the list, then all the others shift one place. React isn’t able to figure this out by itself, it will just assume all elements have changed, and redraw them.

By adding a key React can quickly see if a child element changed position, and just move it instead of redrawing everything.

This means that what we’ve been doing so far, using a card or columns index number, isn’t a great idea. Instead you should use something that isn’t likely to change, like a database primary key.

Let’s give all our cards and columns a unique id. Normally this would come out of a database or something, but a random unique id will do for now.

Because we need both the index and the card itself I’m using map-indexed now instead of for. Pull the card’s id out with some destructuring, and use that as the key instead of the positional index.

And now we do the exact same thing for columns.

(def app-state (r/atom {:columns [{:id (random-uuid)
                                   :cards []}
                                  {:id (random-uuid)
                                   :cards [{:id (random-uuid)
                                            :title "Hello Island."}]}]}))

(defn Column [col-cur]
  ,,,
  (map-indexed (fn [idx {id :id}]
                 ^{:key id}
                 [Card (r/cursor col-cur [:cards idx])])
               cards)
  ,,,)

(defn Board [board]
  ,,,

  (map-indexed (fn [i {id :id}]
                 ^{:key id}
                 [Column (r/cursor state [:columns i])])
               (:columns @board))
  ,,,)

Ok, time to wrap things up. There are still three things missing: add new cards, add new columns, and edit column titles. Let’s first add new cards.

This should start looking familiar, we create an add-new-card function which takes a column cursor and updates it by conj-ing a new card map onto the cards vector. We’ll make sure the card has a unique id, and we already set its editing flag to true so you can immediately type in the card’s title. Pass the column cursor in so we can update it.

Let’s try that… uh yeah and we also need this on-click handler on the div to hook things up. There, that looks good.

(defn add-new-card [col-cur]
  (swap! col-cur update :cards conj {:id (random-uuid)
                                     :title ""
                                     :editing true}))

(defn NewCard [col-cur]
  [:div.new-card
   {:on-click #(add-new-card col-cur)}
   "+ add new card"])

(defn Column [col-cur]
  ,,,
  [NewCard col-cur]
  ,,,
  )

Adding new Columns is pretty much identical to adding cards. We could even consider rolling the components and handler into one, but they are just different enough that in this case I’m going to leave this bit of duplication.

Pass the board atom to the NewColumn component, give it an on-click handler which adds a new column, then implement this add-new-column function to do the work.

Uh yeah these need to be in a map… ok cool, we can add new columns, we’re almost there. All that’s missing is editing the column’s title.

(defn add-new-column [board]
  (swap! board update :columns conj {:id (random-uuid)
                                     :title ""
                                     :cards []
                                     :editing true}))

(defn NewColumn [board]
  [:div.new-column
   {:on-click #(add-new-column board)}
   "+ add new column"])

(defn Board [board]
  ,,,
  [NewColumn board]
  ,,,)

To make column titles editable it does make sense to reuse the functionality we already coded up for the cards. Both cards and columns have an editing flag and a title field, that’s all the event handlers care about. And there are two DOM elements and four event handlers to coordinate, we don’t want to duplicate all of that.

Let’s turn our Card component into a generic Editable component. It’s no longer necessarily a card cursor now, it could also be a column cursor, so rename this variable. The only card-specific thing left is this :div.card with. We can pass that element type in as an argument. The “editing” className we can still add with a property.

The new Card component simply uses the Editable component, passing in the element type: a div with a className of “card”.

(defn Editable [el cur]
  (let [{:keys [editing title]} @cur]
    (if editing
      [el
       {:className "editing"}
       [:input {:type "text"
                :value title
                :autoFocus true
                :on-change #(update-title cur (.. % -target -value))
                :on-blur #(stop-editing cur)
                :on-key-press #(if (= (.-charCode %) 13)
                                 (stop-editing cur))}]]
      [el {:on-click #(start-editing cur)} title])))

(defn Card [card-cur]
  [Editable :div.card card-cur])

So that still works, now let’s look at the Column component. We already had a bit of code in there to deal with the editing flag, but we can replace that now with the Editable component, and there you go. Editing support with no extra effort! The column title is a level two heading, and now it’s the column cursor we’re updating. This element has siblings, so make sure it still has a key.

And… great! You did it, good job!

(defn Column [col-cur]
  ,,,
  [:div.column
   ^{:key "title"} [Editable :h2 col-cur]
   ,,,])

So our app is finished, and we’re all drinking White Russians to celebrate the big launch, when suddenly the Head of QA calls. Turns out auto-focusing of the input fields doesn’t work on iPhone, and this truly will not stand, man.

Now you can choose your own adventure… do you A) slap on a polyfill for autoFocus, or B) decide to get your hands dirty with the DOM.

If you chose A) you can stop the episode and go bowling. If you chose B) then stay put!

Reagent components come in three flavors, known as form-1, form-2, and form-3. So far we’ve only used the simplest of these, the form-1, a simple function which returns Hiccup.

(defn Form1Component [x y z]
  [:div.f1 x y z])

A form-2 component is similar, but instead of returning Hiccup directly, it returns a function which returns Hiccup. The inner function takes the exact same arguments as the component itself.

With a form-2 component, the outer function is only called once, and you can use it to do one-time initialization. You can also use this to create component-local state, by closing over a Reagent atom. The usual caveats about component-local state apply of course, avoid it except for the smallest and simplest of cases.

(defn Component2 [x y z]
  (fn [x y z]
    [:div.c2 x y z]))

(defn Component2 [x y z]
  (let [component-state (r/atom {:loading true})]
    (request-data-from-api :handler (fn [res]
                                      (reset! component-state res)))
    (fn [x y z]
      (if (:loading @component-state)
        "Loading..."
        [:div.c2 x y z]))))

So form-1 and form-2 components are pretty simple, they provide a very Clojuresque way of doing things, it’s only functions after all. Sometimes you need to invoke the full power of the React though. This is where form-3 components come in.

In this case our component is a function which returns the result of Reagent’s create-class function. It’s very similar to the createClass function you might remember from React itself, except that the render method is now called reagent-render. You could use plain render and it would appear to work, but you won’t get automatic updates when a reagent atom changes, so better to always stick with reagent-render.

(defn Component3 [x y z]
  (r/create-class {:display-name "Component3"
                   :reagent-render (fn [x y z]
                                     [:div.c2 x y z])}))

So why add all the extra syntax to achieve the same thing? Well now we have a place to declare React lifecycle methods. We haven’t talked about them much yet, but lifecycle methods power many of the more advanced use cases of React.

To demonstrate this let’s create a component that works just like an <input> form element, but it also automatically focuses itself after it’s rendered. This function is mostly boilerplate, but you can see that in the end it just renders an input field, passing in the component’s properties unchanged. We can use this in our Editable component in place of a regular input field.

(defn AutoFocusInput [props]
  (r/create-class
   {:display-name "AutoFocusInput"
    :reagent-render (fn [props]
                      [:input props])}))


(defn Editable [el cur]
  ,,,
  [AutoFocusInput {:type "text"
                   :value title
                   ,,,}]
  ,,,)

Now we can implement the component-did-mount lifecycle method. Like its name implies, it is called after React is ready “mounting” this component in the DOM, so now there’s a real DOM node that we can access.

component-did-mount receives the component instance, with the dom-node function we can find the actual <input> DOM node, and then call its focus method.

(defn AutoFocusInput [props]
  (r/create-class
   {:display-name "AutoFocusInput"
    :component-did-mount (fn [component]
                           (.focus (r/dom-node component)))
    :reagent-render (fn [props]
                      [:input props])}))

And now our Kanban board is fully functional and we can join our colleagues at the bar again.

In this episode I showed you how to manage state in Reagent using a global atom and cursors. Cursors are great to have in your toolbox, and for simple apps like this they provide an elegant solution.