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

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

1. Note

This blog post is part of a series about developing Clojurescript applications using the Ionic framework.

2. Before you begin

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

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

3. Issues with the created project

While the project works quite well, I personally found that using reagent for the UI does not support taking advantage of modern React best practices, such as using hooks for state, which makes it hard to use many third-party components that depend on React hooks.

There are a few Clojurescript libraries that are more lightweight React wrappers, such as UIx or Helix. They look fine, but unfortunately you can't use re-frame for managing state in those libraries, since re-frame is bound to Reagent.

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

4. Project cleanup

As a first step I upgraded all JavaScript and Clojurescript dependencies and made sure everything still works, and then started 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 the npm-run-all utility, which allows combining multiple npm script commands into one and even running 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

Then update the scripts section of package.json to include the following:

"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 statements in the Clojurescript files, and everything kept working since the refx API is completely compatible with re-frame when it comes to event and subscription definitions.

In our core.cljs file, we first needed to replace the 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 a 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 the Ionic wrapper we created before but using the 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 well documented in Helix Github documentation.

Components are defined using the defnc macro, which claims to have very good performance since it does 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 from refx, which is equivalent to re-frame/subscribe.

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

The 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, the conversion was pretty straightforward. The only thing to note is that the $ macro does only a shallow conversion from Clojurescript 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 a JavaScript object manually, for example by using #js.

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

This is documented in the Helix documentation.

Now, the last component: the 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 the Reagent atom for local state with the actual React state hook provided by Helix. Note how (use-hook "") provides both value and a way to set-value!. Being able to use React hooks directly makes following React documentation and examples much more straightforward than dealing with Reagent atoms.

6. Conclusion

While using Reagent and re-frame as battle-tested Clojurescript libraries is perfectly fine, I personally find benefits in being able to take full advantage of modern React features directly, without losing the benefits of 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