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 part of a series about developing Clojurescript applications using the Ionic framework.

2. Before you begin

Please take a look at part 1, where I showed how to set up Clojurescript to develop an Ionic application.

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

3. Issues with the generated project

When developing the 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.

A bigger issue for me was bloat. The project generated by ionic start has a lot of bloat. TypeScript, web components, and a bunch of libraries and tools – none of these are required when developing in Clojurescript.

And worst of all, hot reload. While the way hot reload works here might be good for people used to JavaScript development, it is far inferior to what we are used to in Clojurescript, since the application is reloaded every time something changes, including the whole application state. And it is not possible to instruct ionic serve to disable hot reload.

4. Create a project

But, upon closer inspection, Ionic is just a UI framework. There's nothing special about it except that it provides a bunch of components typically used in web and mobile applications. It’s Capacitor that provides a runtime. Both can be added to a standard JavaScript project without using Ionic tools.

What if we ditch ionic start and simply create a normal Clojurescript application and add Ionic afterward? 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 the re-frame-template project documentation.

5. Add Ionic

After verifying that everything works, including 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. The Clojurescript compiler is good enough and will not add anything that is not actually used to the release bundle.

To make 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 on Clojars. Until then, you can get it from here.

You also need to add the required stylesheet to your resources/public/index.html file to use Ionic. Unlike Ionic—which supports JavaScript imports of CSS files—these stylesheets need to be included in the head section of the 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 begin 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 a standard reagent or re-frame application, I will skip that part. There are many better tutorials on this topic. I'll focus on introducing Ionic.

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

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

First, we'll create a todo screen and set it as the 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 example uses standard Ionic components for a basic page layout.

Save the file, and the screen will appear in your browser automatically. If you encounter compilation errors due to undefined view components, you can define placeholders that will print the component name instead, 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 to-do 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 a fully functional to-do application example using Ionic and Clojurescript. For reference, the complete `view.cljs` file 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 the example source code on GitHub. Please check the blog-post-1 tag for the source code that corresponds to this tutorial.

9. Introducing Webpack

Now that we have an application that basically works, there are still some issues to address. When you open the console in the browser, there are some JavaScript errors. While we can ignore them for our simple to-do app example, it might bite us in the future when we start working on a bigger app.

Also, the shadow-cljs build reports some warnings. It turns out, Ionic libraries require the webpack JavaScript packaging tool to run properly. Luckily, shadow-cljs is flexible enough that it can be configured to depend on a third-party tool to package required JavaScript dependencies, and compile only ClojureScript code using the :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 with the rest of the dependencies.

First, let's configure 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 an external index file at the given location that includes all requires used 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 the content of ./js/index.js and create a bundle.js file from everything included 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 the Ionic framework as imports.

Now we need to edit our index.html and include the generated bundle file before loading our app.js created by shadow-cljs. We can also remove the import of 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 the bundle, we need to start the webpack in a separate terminal and 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 continue implementing the application. There are no more compilation warnings or runtime errors, and we are good to go.

11. Source code

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

12. Next steps

We might need to look into webpack options to enable additional optimizations. The included webpack configuration file is basic and could probably be further optimized.

Of course, deployment to mobile devices using Capacitor has yet to be attempted. This may be coming in one of the next blog posts.

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