Developing Ionic application using Clojurescript and Helix - To-do App

2023-03-16 - Tutorial showing how to creatine a small Todo application using Clojure tools like shadow-cljs, helix, refx and Ionic framework

1. Note

This blog post is a part of a series of blog posts about developing Clojurescript application using Ionic framework.

2. Before you begin

Please take a look part 2, where I showed how to create a Clojurescript Ionic application using shadow-cljs and webpack.

The result of the second part of tutorial above can be found in phase-2 tag here.

3. Issues with the created project

While the project works quite well, I personally found out that using reagent for the UI doesn't support taking advantage of the modern React best practices, like using hook for state, which make it hard to use many of the third party components that depend on React hooks.

There are a few Clojurescript libraries that arew more lightweight React wrappers, like UIx or helix. They look fine, but unfortunatelly you can't use re-frame for managing state in those libraries, since re-frame is bound to reagent.

After some reserarch I came to this blog post by Ferdinand Beyer, in which he describes the reasons behing creating refx, a re-frame implementation that can be used in both UIx and Helix, and decided to give it a try by converting my exapmle application to helix + refx combo.

4. Project cleanup

As a first step I upgraded all Javascript and Clojurescrip dependencies and made sure everything still works, and then stared converting the application.

Then, I decided to address one more development annoyance of having to run both npx shadow-cljs watch app and npx webpack --watch at the same time in two different terminal windows.

I decided to replace them with npm-run-all utility which allow combining multiple npm script commands into one, and even run them in parallel in the same terminal.

First install npm-run-all

npm install npm-run-all --save-dev
# or
yarn add npm-run-all --dev

And then update script section op package.json to include the following script section:

"scripts": {
    "shadow": "shadow-cljs watch app",
    "release": "shadow-cljs release app",
    "webpack": "webpack --watch",
    "watch": "run-p shadow webpack"
},

Then start both:

npm run watch

This will start both shadow-cljs watch and webpack in the same terminal window.

5. Replacing reagent and re-frame with helix and refx

Replacing re-frame with refx was quite easy. It required only changing the require in Clojurescript files, and everything kept working, since refx API is completely compatible to re-frame when it comes to events and subscriptions definition.

In our core.cljs file, we needed first to replace initialization from reagent to helix:

(defonce root (createRoot (js/document.getElementById "app")))

(defn ^:dev/after-load mount-root []
  (rf/clear-subscription-cache!)
  (.render root ($ views/main-panel)))

We create React root element once, and then re-render it after every recompile.

The biggest changes were in our views.cljs which defines our ui.

First, we needed to import our dependencies

(ns iotodo.views
  (:require
   ["@ionic/react" :as i]
   ["ionicons/icons" :as icons]
   [helix.core :refer [defnc $ <>]]
   [helix.hooks :refer [use-state]]
   [helix.dom :as d]
   [refx.alpha :as rf]))

Note that we are not using Ionic wrapper we created before, but native Ionic dependencies directly.

First, we define our main panel and todo screen:

(defnc todo-screen []
  ($ i/IonApp
     ($ i/IonHeader
        ($ i/IonToolbar
           ($ i/IonTitle "Todo List")))
     ($ i/IonContent {:class "ion-padding"}
        ($ i/IonGrid
           ($ i/IonRow
              ($ todos))))))

(defn main-panel []
  ($ todo-screen))

Here, $ is a helix macro used to embed and render components. It is nicely documented in Helix Github documentation.

Components are defined using defnc macro, which claims to have very good performance since it do as much as possible during compile time.

Then, we create a list of todo items:

(defnc todos []
  (let [todos (rf/use-sub [:todos/all])]
    ($ i/IonList
       ($ input-container)
       (for [todo todos]
         ($ todo-item {:key (:id todo) :& todo})))))

Here, notice the usage of refx/use-sub function from refx, which is an equivalent of re-frame/subscribe.

Also, when rendering todo-item component, note how todo map is passed like a React props.

Todo Item component is defined like this:

(defnc todo-item [{:keys [id text checked]}]
  ($ i/IonItem
     ($ i/IonCheckbox
        {:slot "start" :checked checked
         :onIonChange #(rf/dispatch [:todos/check id])})
     ($ i/IonText
        {:style #js{:textDecoration (if checked "line-through" "none")}}
        text)
     ($ i/IonButton
        {:slot "end" :iconOnly true
         :onClick #(rf/dispatch [:todos/delete id])}
        ($ i/IonIcon {:icon icons/trash}))))

Again, conversion was pretty straightforward. The only thing to note is that $ macro is doing only shallow conversion from Clojurescrip to Javascript of the properties map, so if you have nested maps there, like the :style map in this example, it needs to be converted to Javascript object manually, like using #js in this example.

Another difference is that $ convert from kebab-case to CamelCase property keys only in case of native HTML components, but not in case of any third party component libarary, like Ionic. That's why we had to use :textDecoration and :onIonChange in this example, and not :text-decoration and :on-ion-change like in the reagend samples.

This is documentsed in Helix documentation.

Now, to the last component, input container for adding new todos:

(defnc input-container []
  (let [[value set-value!] (use-state "")]
    ($ i/IonItem
       ($ i/IonTextarea {:slot "start" :placeholder "Todo ..."
                         :autoGrow true :autofocus true
                         :inputmode "text"
                         :value value
                         :onIonChange #(set-value! (.. % -detail -value))})
       ($ i/IonButton {:slot "end" :iconOnly true
                       :onClick (fn []
                                  (rf/dispatch [:todos/add value])
                                  (set-value! ""))}
          ($ i/IonIcon {:icon icons/add})))))

Here we replaced reagent atom for keeping local state with the actual React state hook which is provided by Helix. Note how (use-hook "") provides both value and a way to set-value!. Being able to ues React hooks directly, makes following React documentation and examples much more straightforward that having to deal with reagent atoms.

6. Conclusion

While using reagent and re-frame as battle proved Clojurescript libraries is perfectly fine, I find personally benefits of being able to take full advantage of using more modern React features directly, without losing benefits of the re-frame.

Final code can be found in the phase-3 tag on the GitHub project here.

Keywords: ionic clojure clojurescript react programming javascript helix shadow-cljs refx webpack