Get Reagent powers with React Hooks and 6 lines of ClojureScript
And a 4 lines macro.
The default way to write UI applications in ClojureScript is to use React or its cousin React Native.
For years people had to choose a wrapper that would expose a functional API over React object-oriented API. Most popular wrappers are:
I dare to say that, as of today, the most popular one is Reagent.
Let's examine how React hooks represent a deep viable alternative to Reagent. We'll do so interactively by using Yehonathan Sharvit's wonderfull Klipse library.
Feel free to modify the code to experiment its behaviour.
Reagent documentation describes three ways to declare components: form 1, form 2 and form 3:
- Form 1 is simply displaying information with parameterized data.
- Form 2 is handling component local state.
- Form 3 is handling advanced component lifecycles.
Reagent wraps traditional object component declaration with a nice functional interface. React hooks actually make it possible to have the same expressivity (and even better for form 3 IMHO) in ClojureScript with a 6 lines function and a 4 lines macro:
The main function of our tiny wrapper is the create-element
one:
(ns blog.core)
(defn create-element
([component] (create-element component nil))
([component attributes & children]
(if (map? attributes)
(apply js/React.createElement component (clj->js attributes) children)
(apply js/React.createElement component nil attributes children))))
Once we have that, we can declare our HTML tags this way
(def div
(partial create-element "div"))
Note that this code is platform agnostic. We can similarly declare React Native elements:
(def scroll-view
(partial create-element (-> "react-native" js/require .-ScrollView)))
Humm, writing this for every HTML tag can be cumbersome... 🤔
But let's remember that Clojure has a unique superpower: the macro system 😎
(ns blog.core$macros) ;; Enter the special macros namespace
(defmacro def-elems [elems]
`(do ~@(for [[sym# component#] elems]
`(def ~sym#
(partial blog.core/create-element ~component#)))))
(ns blog.core) ;; Switch back to the regular namespace
(blog.core/def-elems {div "div"
a "a"
p "p"
img "img"
h5 "h5"
h6 "h6"
button "button"
ul "ul"
li "li"})
Note: the macro might be a bit hard to read for non-clojure developpers. It sequentially defines tags with the previously described form.
Form 1
Let's define the two same components with Reagent and our tiny wrapper.
First in Reagent
(require '[reagent.core :as r])
(require '[reagent.dom :as rd])
Â
(defn form-1-reagent []
[:div.card
[:div.card-body
[:h5.card-title
"Form 1"]
[:h6.card-subtitle.mb-2.text-muted
"With Reagent"]
[:p.card-text
"The quick brown fox jumps over the lazy dog"]]])
Â
(rd/render
[form-1-reagent]
(js/document.getElementById "form-1-reagent"))
Loading ...
And the same one with our tiny wrapper
(defn form-1-tiny-wrapper []
(div {:className "card"}
(div {:className "card-body"}
(h5 {:className "card-title"}
"Form 1")
(h6 {:className "card-subtitle mb-2 text-muted"}
"With our tiny wrapper")
(p {:className "card-text"}
"The quick brown fox jumps over the lazy dog"))))
Â
(js/ReactDOM.render
(create-element form-1-tiny-wrapper)
(js/document.getElementById "form-1-tiny-wrapper"))
Loading ...
We traded square brackets for parentheses and keywords for symbols. But the general tree description is the same and we keep the ability to have structural editing.
Form 2
Now, let's define a component with internal state: a click counter.
First in Reagent.
(defn form-2-reagent []
(let [click-count* (r/atom 0)]
(fn []
[:div.card
[:div.card-body
[:h5.card-title
"Reagent click counter"]
[:p.card-text
(str "You clicked " @click-count* " times")]
[:button.btn.btn-primary {:onClick #(swap! click-count* inc)}
"Increment"]]])))
Â
(rd/render
[form-2-reagent]
(js/document.getElementById "form-2-reagent"))
Loading ...
And then with our tiny wrapper.
(defn form-2-tiny-wrapper []
(let [[click-count set-click-count] (js/React.useState 0)]
(div {:className "card"}
(div {:className "card-body"}
(h5 {:className "card-title"}
"Tiny wrapper click counter")
(p {:className "card-text"}
(str "You clicked " click-count " times"))
(button {:className "btn btn-primary"
:onClick #(-> click-count inc set-click-count)}
"Increment")))))
Â
(js/ReactDOM.render
(create-element form-2-tiny-wrapper)
(js/document.getElementById "form-2-tiny-wrapper"))
Loading ...
The two versions are rather similar in expressivity but the tiny wrapper version doesn't suffer from the "Rookie mistake" of Reagent form 2.
form 3
Now let's complexify a bit more our UI to require some component lifecycle handlers. Let's build a two panes block. Every time a pane will be displayed, it will count for how long it was opened.
First in Reagent.
(require '[reagent.core :as r])
(require '[reagent.dom :as rd])
Â
(defn form-3-reagent-sub-comp [title]
(let [secs* (r/atom 0)
id* (atom nil)]
(r/create-class
{:display-name "form-3-reagent-sub-comp"
:component-did-mount
(fn []
(->> (js/setInterval #(swap! secs* inc)
1000)
(reset! id*)))
:component-will-unmount
(fn []
(js/clearInterval @id*))
:reagent-render
(fn [title]
[:div.card
[:div.card-body
[:h5.card-title
title]
[:p.card-text
(str "Tab opened for " @secs* " seconds")]]])})))
Â
(defn form-3-reagent []
(let [active-tab* (r/atom :first)]
(fn []
[:div.p-2
[:ul.nav.nav-tabs
[:li.nav-item
[:a.nav-link {:className (when (= @active-tab* :first) "active")
:href "#"
:onClick (fn [e]
(.preventDefault e)
(when (not= @active-tab* :first)
(reset! active-tab* :first)))}
"Page 1"]]
[:li.nav-item
[:a.nav-link {:className (when (= @active-tab* :second) "active")
:href "#"
:onClick (fn [e]
(.preventDefault e)
(when (not= @active-tab* :second)
(reset! active-tab* :second)))}
"Page 2"]]]
^{:key (name @active-tab*)}
[form-3-reagent-sub-comp (case @active-tab*
:first "First component"
:second "Second component"
"Unknown component")]])))
Â
(rd/render
[form-3-reagent]
(js/document.getElementById "form-3-reagent"))
Loading ...
And then with our tiny wrapper.
(defn form-3-tiny-wrapper-sub-comp [props]
(let [{:keys [title]} (js->clj props :keywordize-keys true)
[secs set-secs] (js/React.useState 0)]
(js/React.useEffect
(fn start-timer []
(let [secs* (atom secs)
id (js/setInterval #(-> secs* (swap! inc) set-secs)
1000)]
(fn stop-timer []
(js/clearInterval id))))
#js [])
(div {:className "card"}
(div {:className "card-body"}
(h5 {:className "card-title"}
title)
(p {:className "card-text"}
(str "Tab opened for " secs " seconds"))))))
Â
(defn form-3-tiny-wrapper []
(let [[active-tab set-active-tab] (js/React.useState :first)]
(div {:className "p-2"}
(ul {:className "nav nav-tabs"}
(li {:className "nav-item"}
(a {:className (cond-> "nav-link"
(= active-tab :first) (str " active"))
:href "#"
:onClick (fn [e]
(.preventDefault e)
(when (not= active-tab :first)
(set-active-tab :first)))}
"Page 1"))
(li {:className "nav-item"}
(a {:className (cond-> "nav-link"
(= active-tab :second) (str " active"))
:href "#"
:onClick (fn [e]
(.preventDefault e)
(when (not= active-tab :second)
(set-active-tab :second)))}
"Page 2")))
(create-element form-3-tiny-wrapper-sub-comp
{:key (name active-tab)
:title (case active-tab
:first "First component"
:second "Second component"
"Unknown component")}))))
Â
(js/ReactDOM.render
(create-element form-3-tiny-wrapper)
(js/document.getElementById "form-3-tiny-wrapper"))
Loading ...
There are now noticable differences between form-3-tiny-wrapper-sub-comp
and form-3-reagent-sub-comp
.
It might be a matter of taste but on one hand (OO approach), there is logic hidden behind some semantically strong method names. But the internal mechanics of the component is hidden.
On the other hand (IMO), the hook version expresses the logic explicitly with functional programming basic tools: functions, closures and data. The whole component logic feels to be right under the eyes and fingers.
Also, another effect not noticeable here is that hooks tends to group business logic in coherent blocks when OO approach tends to spread this logic all around the class. It is well illustrated in this article (scroll down to the screenshot with colored squares).
Let's wrap up
There is a big non obvious difference here. With Reagent we have two documentations (React one and Reagent one). With our wrapper, there is one: the React official one.
Also Reagent props are not React props. Reagent magic brings some complexifications in some edge cases.
When React was only accessible through OO programming, Reagent brought a very nice functional interface to ClojureScript programmers. React hooks deeply change the situation in the ClojureScript land.
And beyond...
React and Reagent treat only about local state management, at the component level. But handling the global state of a whole application is another affair, and we have to turn toward other libraries (or manually crafting it around the ideas of Flux). This article is indeed the preamble of a shortly coming second article that will present a new library that deals with global state management.
Damien RAGOUCY