Developing Ionic application with Clojurescript - To-do App

2022-02-21 - Tutorial showing how to create a small Todo application using standard Clojure tools like shadow-cljs, re-frame 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 1, where I showed how to create set up a Clojurescript to develop an Ionic application.

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

3. Issues with the generated project

When developing first part, I noticed some issues. For example, the code was too verbose, every time I had to write [:> ion/IonButton ...] instead of just [ion/button ...] That kind of verbosity can be overcome by having a wrapper over the Ionic UI components.

Bigger issue for me was bloat. The project generated by ionic start has a lot of bloat. Typescript, Web components, a bunch of libraries and tools. None of these is required when developing in Clojurescript.

And worst of all, hot reload. While the way hot reload here works might be good for people used to Javascript development, it is way too inferior to what we got used when working with Clojurescript, since application was reloaded every time anything changes, including the whole application state. And it is not possible to tell ionic serve to disable hot reload.

4. Create a project

But, by having a deeper look, Ionic is just a UI framework. There's nothing special about it, except that it provides a bunch of components typicaly used in web and mobile applications. It's the Capacitor that provides a runtime. And both can be added to a standard Javascript project, without using Ionic tools.

What about if we ditch ionic start, just create a normal Clojurescript application and add Ionic afterwards? Let's just do it.

We will use re-frame-template to create a standard re-frame project with a pair of goodies built in.

lein new re-frame iotodo +cider +10x +re-frisk
cd iotodo

For a full list of options and how to use it, please check re-frame-template project documentation.

5. Add Ionic

After verifying everything works, including the hot reload, let's add Ionic and some related packages.

npm install @ionic/react
npm install @ionic/react-router
npm install ionicons
npm install @capacitor/app
npm install @capacitor/core
npm install @capacitor/haptics
npm install @capacitor/keyboard
npm install @capacitor/status-bar

I added a bunch of packages that may not be required, but I don't care. Clojurescript compiler is good enough and it will not add anything that is not actually used to the relaese bundle.

To make a working with Ionic more pleasant, I created an ion.core package that wraps all the Ionic components into reagent components. Something like this:

(ns ion.core
  (:refer-clojure :exclude [range list])
  (:require
   [reagent.core :as r]
   ["@ionic/react" :as i]))

(defn- adapt-class [class]
  (when class
    (r/adapt-react-class class)))

;; Action Sheet
(def action-sheet (adapt-class i/IonActionSheet))
;; Accordion
(def accordion (adapt-class i/IonAccordion))
(def accordion-group (adapt-class i/IonAccordionGroup))
...

If I don't get lazy I may publish this as a library in Clojars. Until then you can get it from here.

You also need to add required stylesheet to your resources/public/index.html file in order to use Ionic. Unlike in Ionic which support Javascript import of Javafiles, these actually needs to be included in head section of index file.

Full file included as a reference.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset='utf-8'>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>iotodo</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/react@6.0.8/css/ionic.bundle.css" />
  </head>
  <body>
    <noscript>
      iotodo is a JavaScript app. Please enable JavaScript to continue.
    </noscript>
    <div id="app"></div>
    <script src="/js/compiled/app.js"></script>
  </body>
</html>

Let's start the application and start developing.

npm run watch
# or npx shadow-cljs watch app

6. Data model

Since the purpose of this tutorial is not to show how to develop standard reagent or re-frame application, I will skipp that part. There are much better tutorials on the topic. I'll focus on introducing Ionic.

If you want to take a look at the re-frame specific part, please check iotodo.db, iotodo.events and iotodo.subs namespaces.

UI code using Ionic will be located under iotodo.view.

First, we'll create a todo screen and set it as application main-panel

(defn todo-screen []
  [ion/app
   [ion/header
    [ion/toolbar
     [ion/title "Todo List"]]]
   [ion/content {:class "IosPadding"}
    [ion/grid
     [ion/row
      [input-container]]
     [ion/row
      [todos]]]]])

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

This uses standard Ionic components for a basic page layout.

Save it, and the screen will appear in your browser automaticlly. If you encounter compilation errors due to undefined view components, you may define placeholders that will just print a component name instead. Something like this:

(defn input-container []
  "input-container")

Once you have a basic application layout, you can implement a list of items:

(defn todo-item [{:keys [key text checked]}]
  [ion/item {:key key}
   [ion/checkbox {:slot "start" :checked checked
                  :on-ion-change #(rf/dispatch [:todos/check key])}]
   [ion/text
    {:style {:text-decoration (if checked :line-through :none)}} text]

   [ion/button {:slot "end" :icon-only true
                :on-click #(rf/dispatch [:todos/delete key])}
    [ion/icon {:icon icons/trash}]]])

(defn todos []
  (let [todos (rf/subscribe [:todos/all])]
    [ion/list
     (for [todo @todos]
       [todo-item todo])]))

7. Input component

Now that the list management is implemented, the only thing left is the implementation of the component that will create a new todo item.

(defn- input-container []
  (let [value (r/atom "")]
    (fn []
      [ion/item
       [ion/textarea {:slot :start :placeholder "Todo ..."
                      :auto-grow true :autofocus true
                      :inputmode "text"
                      :value @value
                      :on-ion-change #(reset! value (.. % -detail -value))}]
       [ion/button {:slot "end" :icon-only true
                    :on-click (fn []
                                (rf/dispatch [:todos/add @value])
                                (reset! value ""))}
        [ion/icon {:icon icons/add}]]])))

Now we have fully functionaly todo application example using Ionic and Clojurescript. For the reference complete view.cljs looks like this:

(ns iotodo.views
  (:require
   [reagent.core :as r]
   [re-frame.core :as rf]
   [ion.core :as ion]
   ["ionicons/icons" :as icons]
   [iotodo.subs :as subs]))

(defn- input-container []
  (let [value (r/atom "")]
    (fn []
      [ion/item
       [ion/textarea {:slot :start :placeholder "Todo ..."
                      :auto-grow true :autofocus true
                      :inputmode "text"
                      :value @value
                      :on-ion-change #(reset! value (.. % -detail -value))}]
       [ion/button {:slot "end" :icon-only true
                    :on-click (fn []
                                (rf/dispatch [:todos/add @value])
                                (reset! value ""))}
        [ion/icon {:icon icons/add}]]])))

(defn- todo-item [{:keys [key text checked]}]
  [ion/item {:key key}
   [ion/checkbox {:slot "start" :checked checked
                  :on-ion-change #(rf/dispatch [:todos/check key])}]
   [ion/text
    {:style {:text-decoration (if checked :line-through :none)}} text]

   [ion/button {:slot "end" :icon-only true
                :on-click #(rf/dispatch [:todos/delete key])}
    [ion/icon {:icon icons/trash}]]])


(defn- todos []
  (let [todos (rf/subscribe [:todos/all])]
    [ion/list
     [input-container]
     (for [todo @todos]
       [todo-item todo])]))

(defn- todo-screen []
  [ion/app
   [ion/header
    [ion/toolbar
     [ion/title "Todo List"]]]
   [ion/content {:class "IosPadding"}
    [ion/grid
     [ion/row
      [todos]]]]])

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

8. Source code

You can find an example source code on GitHub. Please check blog-post-1 tag for the source code that corresponds to this tutorial.

9. Introducing Webpack

Now that we have application that basicaly works, there are still some issues to address. When you open a console in the browser, there are some javascript errors. While we can ignore then for our simple todo-app example, it may bite us in the future when we start working on a bigger app.

Also, shadow-cljs build reports some warnings. It turns out, ionic libraries require a webpack Javascript packaging tool to run properly. Luckily, shadow-cljs is flexible enough that it can be configured to depend on third party tool to package required Javascript dependencies, and compile only Clojurescrip code using :js-options configuration.

First, let's add webpack to the project.

npm install --save-dev webpack
npm install --save-dev css-loader
npm install --save-dev style-loader

We also added css-loader and style-loader modules since we'll need them for bundling ionic css files together witht the rest of dependencies.

First, let's configuere our shadow-cljs.edn as explained above:

 :builds
 {:app
  {:target     :browser
   :output-dir "resources/public/js/compiled"
   :asset-path "/js/compiled"
   :js-options {:js-provider :external
                :external-index "js/target/requires.js"}
...

This will tell shadow-cljs to generate external index file at the given location that includes all requires uded in our Clojurescript application.

Now we need to configure Webpack. Create the webpack.config.js file with the following configuration:

const path = require('path');

module.exports = {
    mode: "production",
    entry: ['./js/index.js'],
    output: {
        path: path.resolve(__dirname, 'resources/public/js/libs'),
        filename: 'bundle.js',
        clean: true,
    },
    module: {
        rules: [
            {
                test: /\.m?js/,
                resolve: {
                    fullySpecified: false,
                }
            },
            {
                test: /\.css$/,
                use: ['style-loader', 'css-loader']
            }
        ]
    },
};

This will tell webpack to get content of ./js/index.js and create bundle.js file from everything includede from there. We need to create that ./js/index.js like this:

import './target/requires.js';

/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';

/* Basic CSS for apps built with Ionic */
import '@ionic/react/css/normalize.css';
import '@ionic/react/css/structure.css';
import '@ionic/react/css/typography.css';

/* Optional CSS utils that can be commented out */
import '@ionic/react/css/padding.css';
import '@ionic/react/css/float-elements.css';
import '@ionic/react/css/text-alignment.css';
import '@ionic/react/css/text-transformation.css';
import '@ionic/react/css/flex-utils.css';
import '@ionic/react/css/display.css';

This will import files with our requires generated by shadow-cljs as well as add css files required by ionic framework as import.

Now we need to edit our index.html and include generated bundle file before loading our app.js created by shadow-cljs. We can also remove importing the css file, since it's now included in a library bundled by webpack.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset='utf-8'>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>iotodo</title>
  </head>
  <body>
    <noscript>
      iotodo is a JavaScript app. Please enable JavaScript to continue.
    </noscript>
    <div id="app"></div>
    <script src="/js/libs/bundle.js"></script>
    <script src="/js/compiled/app.js"></script>
  </body>
</html>

10. Compiling and starting the application

In order to create bundle we need to start the webpack in a separate terminal ald let it watch for changes.

npx webpack --watch

We do the same with shadow-cljs:

npx shadow-cljs watch app

Now we can open http://localhost:8280 and keep implementing the application. There's no more compile warnings or runtime errors, and we are good to go.

11. Source code

You can find a final example source code for this tutorial on GitHub. Please check blog-post-2 tag for the source code that corresponds to this tutorial at the time of writing.

12. Next steps

We may need to look into webpack options to enable some more optimizations. Webpack configuration file included here is basic, and probably can be further optimized.

Of course, deployment to mobile devices using Capacitor is yet to be tried. This may be comming in one of the next blog posts.

Keywords: ionic clojure clojurescript react programming javascript shadow-cljs reagent re-frame webpack