Skip to main content

gλippi

Pensino ora i miei venticinque lettori...

Let's build a ClojureScript PWA

In this post we'll go through the details on how to create a very simple PWA with ClojureScript. Some of the prerequisites to follow through are a general idea of what is a PWA, a basic knowledge of ClojureScript, familiarity with shadow-cljs and reagent, as we'll use shadow-cljs to build the project and reagent to render the HTML.

1. Bootstrap a shadow-cljs project #

The first step is to create a shadow-cljs project, we can do it easily following this template. Let's clone the repo then:

git clone https://github.com/shadow-cljs/quickstart-browser.git cljs-pwa

and then enter the newly created project's directory

cd cljs-pwa

and install the dependencies and run the server

npm install && npx shadow-cljs server

The first startup takes a bit of time since it has to download all the dependencies and do some prep work. Once this is running we can get started running:

npx shadow-cljs watch app

You can now visit the app at http://localhost:8020.

1.1 Install react and reagent #

Now we need to add react and reagent, which is the ClojureScript wrapper around react. To do so we can add to the package.json dependecies react, at the version 17.0.2 as the official reagent-template does.

{
  "devDependencies": {
    "shadow-cljs": "^2.26.2"
  },
  "dependencies": {
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  }
}

And then add reagent latest version to shadow-cljs.edn.

;; shadow-cljs configuration
{:source-paths
 ["src/dev"
  "src/main"
  "src/test"]

 :dependencies [[reagent "1.2.0"]]

 :dev-http
 {8020 "public"}

 :builds
 {:app
  {:target :browser
   :output-dir "public/js"
   :asset-path "/js"

   :modules
   {:main ; becomes public/js/main.js
    {:init-fn starter.browser/init}}}}}

1.2 The BMI calculator app #

Now that we have the deps that we need, we can create a small app with reagent, we're going to copy the example in the official documentation of reagent on the BMI calculator. Add a new file bmi.cljs in src/main/starter/ and copy this code

(ns starter.bmi
  (:require [reagent.core :as r]))

(defn calc-bmi [{:keys [height weight bmi] :as data}]
  (let [h (/ height 100)]
    (if (nil? bmi)
      (assoc data :bmi (/ weight (* h h)))
      (assoc data :weight (* bmi h h)))))

(def bmi-data (r/atom (calc-bmi {:height 180 :weight 80})))

(defn slider [param value min max invalidates]
  [:input {:type "range" :value value :min min :max max
           :style {:width "100%"}
           :on-change (fn [e]
                        (let [new-value (js/parseInt (.. e -target -value))]
                          (swap! bmi-data
                                 (fn [data]
                                   (-> data
                                       (assoc param new-value)
                                       (dissoc invalidates)
                                       calc-bmi)))))}])

(defn bmi-component []
  (let [{:keys [weight height bmi]} @bmi-data
        [color diagnose] (cond
                           (< bmi 18.5) ["orange" "underweight"]
                           (< bmi 25) ["inherit" "normal"]
                           (< bmi 30) ["orange" "overweight"]
                           :else ["red" "obese"])]
    [:div
     [:h3 "BMI calculator"]
     [:div
      "Height: " (int height) "cm"
      [slider :height height 100 220 :bmi]]
     [:div
      "Weight: " (int weight) "kg"
      [slider :weight weight 30 150 :bmi]]
     [:div
      "BMI: " (int bmi) " "
      [:span {:style {:color color}} diagnose]
      [slider :bmi bmi 10 50 :weight]]]))

then add to src/main/starter/browser.cljs and this code in the start function:

(defn ^:dev/after-load start []
  (js/console.log "start")
  (rdom/render [bmi-component]
               (.getElementById js/document "app")))

It's also better to get rid of the <h1/> element in public/index.html, so the new index.html should look like this:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="/css/main.css">
  <title>Browser Starter</title>
</head>
<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <div id="app"></div>
  <script src="/js/main.js"></script>
</body>
</html>

BMI pwa app

If you made it so far, congratulations! You've just create a small app, now let's move on and let's make it a PWA ;) #

2 Setup the PWA #

In order to have fully installable PWA, we must meet these requirements:

2.1 Create an iconset #

We need to create an icon for our app and make it available in different formats. I did one with chatGPT of course 😄, and you can see it here

2.1 Create the manifest.json #

{
  "short_name": "BMI calculator",
  "name": "BMI calculator",
  "icons": [
    {
      "src": "icons/icon_x48.png",
      "sizes": "48x48",
      "type": "image/png"
    },
    {
      "src": "icons/icon_x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "icons/icon_x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "icons/icon_x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "icons/icon_x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "icons/icon_x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "icons/icon_x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
  "start_url": "/",
  "background_color": "#ffffff",
  "display": "standalone",
  "scope": "/",
  "theme_color": "#000000"
}

2.2 Add the service worker #

// service-worker.js
const CACHE_NAME = 'my-pwa-cache-v1';
const urlsToCache = [
  '/',
  '/js/app.js',
  '/css/style.css', // Add other resources you want to cache
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(response => {
        if (response) {
          return response;
        }
        return fetch(event.request);
      }
    )
  );
});

2.3 Add a theme color for the address bar #

In the index.html file, add in the <head> tag this code

  <meta name="theme-color" content="#ffffff">

2.4 Check Google Chrome's Lightouse PWA test #

Your code should now look more or less to something like in this repository at this commit If you run the app now, after all these changes and open the developer tools on Chrome, you can then run the Lightouse analysis for PWA and you should see that everything is green, like in the image below:

Lighthouse test

By now you should already see a new icon in the address bar which suggest that you can install the app, like this one:

Install the app

But we can add a last touch to our PWA: let's add button to suggest the installation when the user first load the app.

2.4 Add a button to install the app #

To prompt the user to install the app we need to listen for the event beforeinstallprompt, so we can just add this little snippet to our main file:

(defonce install-prompt (r/atom nil))

(defn install-prompt-component []
  (let [prompt-event @install-prompt]
    (when prompt-event
      [:button {:style {:margin-top "30px"
                        :padding "10px"
                        :border-radius "5px"}
                :on-click #(do (.prompt prompt-event)
                               (reset! install-prompt nil))}
       "Install the BMI calculator!"])))

(defn app []
  [:<>
   [bmi-component]
   [install-prompt-component]])

;; start is called by init and after code reloading finishes
(defn ^:dev/after-load start []
  (js/console.log "start")
  (.addEventListener
   js/window
   "beforeinstallprompt"
   (fn [e]
     (.preventDefault e)         ;; Prevent the mini-infobar from appearing on mobile
     (reset! install-prompt e)))

  (rdom/render [app]
               (.getElementById js/document "app")))

This will add a button, that when clicked will ask the user if he wants to install the app.

So that's it, now we have a fully functional, even though very simple, PWA built with ClojureScript. Please consider that the styles of the app are absolutely horrible at the moment, but I wanted to focus mainly on the steps needed to get from 0 to a deployable PWA, so I spent literally the minimum to thik about the styles, if you're eyes will bleed have mercy on me 😄

The repo with the code is here