This is a follow-up to my previous The Lost Arts of CLJS Frontend post. I promised a demo and this post is about that.

Rather than trying to come up with a full demo myself, I opted to re-use the eelchat repo which is part of the biff tutorial. I don’t really want to talk about biff in this post, since I’m not actually familiar with it myself. It doesn’t really matter which backend stack you use. eelchat does have everything already setup, was not an SPA, and I only had to replace very minor HTMX+hyperscript uses. The code of my fork is on github.

I’m assuming you have the clojure tools and babashka installed on your system. node, and npm which comes with it, is useful too, but optional. More on that later.

Disclaimer

At no point do I want this post to appear to be about criticizing HTMX, hyperscript or biff. It just happened to be one of the few available public demos, where it was easy enough to show how to introduce CLJS and adding/replacing some basic functionality.

I’m not claiming that what I started here is comparable to what the previous HTMX+hyperscript setup was. Clearly those libs are capable of way more than was used, and my code only does exactly what was written.

This is about showing how to start using CLJS in a lightweight manner with a basic setup, that can adapt to any needs.

The Setup

The original eelchat implementation uses biff as the CLJ backend, with HTMX+hyperscript for the frontend JS needs. They were included via CDN links, so absolutely no build step or anything of that sort existed for JS. In intended HTMX fashion, the CLJ backend has a few REST-type routes accepting GET,POST,DELETE requests that return server-rendered HTML snippets. For all intents and purposes the client generates no HTML at all, and we continue with that approach.

The biff tailwindcss integration didn’t work on my machine, so I installed it via npm. The project did not have a package.json yet, so I started by running npm init -y in the project to create it. After that a quick npm install tailwindcss @tailwindcss/forms and of course npm install shadow-cljs. That gives us everything from the npm side. As mentioned, this is optional! I have npm installed and don’t mind using it. You can skip this, if you’d rather not use npm/node.

Then I created the shadow-cljs.edn config file and build config.

{:deps {:aliases [:client]}
 :builds
 {:client
  {:target :browser
   :output-dir "resources/public/js"
   :modules {:main {:init-fn com.eelchat.client/init}}
   }}}

A brief introduction on what that all means: We tell shadow-cljs to build the :client build for the browser, outputting everything to the resources/public/js folder. :modules :main instructs shadow-cljs to build a main.js file in that folder. :init-fn instructs shadow-cljs to call (com.eelchat.client/init) when that file is loaded by the browser. This is enough config to take you very far, you can adapt it for possible future needs when you need it. This is not something you need to worry about at the start and possibly never.

Since the project is already setup using deps.edn I opted to stay with that for demo purposes, so we also need to add our :client alias to the existing deps.edn file.

{:paths ...
 :deps {...}

 :aliases
 {:client
  {:extra-deps
   {com.thheller/shadow-graft {:mvn/version "0.9.0"}
    thheller/shadow-cljs {:mvn/version "2.24.1"}}}}}

This is also optional, and you could have instead stayed with just shadow-cljs.edn. But it is often easier for tools (i.e. Cursive in my case) to stay with only one dependency resolution mechanism (i.e. deps.edn).

In case you want to try this but not touch any existing files the config would look like this:

{:source-paths ["src"]
 :dependencies [[com.thheller/shadow-graft "0.9.0"]]
 :builds
 {:client
  {:target :browser
   :output-dir "resources/public/js"
   :modules {:main {:init-fn com.eelchat.client/init}}
   }}}

Basically the only thing changed is that shadow-cljs will now resolve :dependencies and construct the classpath itself, instead of delegating that to the deps.edn and tools.deps clj tooling. Entirely up to you on what you prefer, you will of course need the shadow-cljs npm tool if you opt to only use shadow-cljs.edn. The shadow-cljs tool knows about itself, so we can skip the thheller/shadow-cljs dependency we needed in deps.edn, in case you were wondering where that went.

Next we need to create the actual src/com/eelchat/client.cljs file and namespace. At the bare minimum we need the init fn, since the build :init-fn will attempt to call it. So, I started with that.

(ns com.eelchat.client)

(defn init []
  (js/console.log "hello world"))

Now that we have some code, it is time to start firing up the build and server. We start the backend via bb dev and the CLJS build separately via npx shadow-cljs watch client. If you opted out of npm and want to stay entirely with CLJ tooling you run clj -A:client -M -m shadow.cljs.devtools.cli watch client instead.

Next we can remove HTMX and start loading our main.js file instead, which is done like this. Technically just a script tag in the head would have been enough, but I have made good experiences using the rel="preload" together with an <script async> at the end of the body. In the end all we need is the <script src="/js/main.js"></script> tag somewhere in our final HTML, but depending on where it is we might need to adjust the init fn (e.g. use DOMContentLoaded event). I consider what I have done above best-practice and recommend to follow it.

The Workflow

Once everything is running and built you should be able to open http://localhost:8080/. If everything works you should see the hello world in the browser console we logged in the init fn. If the page looks unstyled, the CSS creation by bb dev might not have worked. It didn’t for me, so I ran npx tailwindcss -c resources/tailwind.config.js -i resources/tailwind.css -o resources/public/css/main.css once to get the CSS.

We won’t be taking advantage of CLJS hot-reload, so any CLJS changes we make will require manually reloading the whole page. This is the same as it was in the HTMX workflow. We do get some compilation feedback in the browser, so errors should be easily spotted.

You can add a small helper hook function to our namespace, so that shadow-cljs reloads the page for us on any CLJS change. Not something I personally like, but it is possible if you want it.

(defn ^:dev/after-load reload-page! []
  (js/document.location.reload))

As per my previous post we will be using the “graft” technique with the shadow-graft library. So, the first step is loading it and setting it up in our namespace.

(ns com.eelchat.client
  (:require
    [shadow.graft :as graft]
    [cljs.reader :as reader]))

(defn init []
  (graft/init reader/read-string js/document.body))

This setup uses the standard CLJS reader to read EDN data we get from our backend. We could use JSON and shave a couple of kilobytes of our build, but I prefer and recommend EDN, or transit if you already use it for other stuff.

Since the server doesn’t have any grafts yet, we will add that next. Since we replaced HTMX in the backend I searched for places it was used by looking for :hx-* and :_ keyword uses.

The first use was dedicated to deleting a channel, the HTMX and replacement graft are here. Basically the UI lists channels in a left pane, and displays an X button to delete that channel. The process is replacing the HTMX attributes with a graft point.

The second was the channel chat UI itself. It is basically a list of chat messages with a textarea input to author new messages and a submit button to send it.

biff already reloads the CLJ server side code on its own, so our changes should be picked up automatically, and we should see the new HTML accordingly when we reload our browser.

In the frontend you’ll need to enter an email first. The actual invite link is printed to the terminal where bb dev is running, no email is sent in dev. Once you follow that you are logged in and can create a new communities and channels, since no client side code was required to do that.

The chat won’t do much, since we need to create the code the server tried to call first.

Let’s start with the “channel-delete” graft scion, since it is smaller.

(defmethod graft/scion "channel-delete"
  [{:keys [href active title community] :as opts} button]
  (.addEventListener button "click"
    (fn [e]
      (.preventDefault e)
      (when (js/confirm (str "Delete " title "?"))
        (js-await [body (req href {:method "DELETE"})]
          (if active
            (set! js/window.location -href (str "/community/" community))
            (.remove (.-parentElement button))
            ))))))

opts is the data passed by the backend code, which again is just plain EDN, exactly what you had on the server.

(graft "channel-delete" :parent
  {:href href
   :active active
   :title (:chan/title chan)
   :community (:xt/id community)})

We then add a click event listener to the button DOM element the graft targeted. When clicked we ask the user to confirm if they actually want to delete. Once confirmed we send a DELETE request to the backend. If the channel was the currently active one we redirect which will make the browser reload and show the community page. If the channel was not active, we just delete the div element containing the delete button. Doing pretty much what the HTMX did for us previously, just via CLJS.

Next, we add the “channel-ui” graft, which again just does what the HTMX did previously. Waiting for the user to submit some new text, which we then send to the server and append the result to the #messages div.

In addition, it sets up the Websocket connection. I couldn’t figure out why the backend never sends any actual websocket messages, when opening a second browser to chat with. The HTMX variant didn’t either, so I didn’t investigate further since this post is already getting long enough. This is about the technique in general, not how to use websockets after all. The basic code to get started is there, and it might work if you adjust the server to actually send WS messages.

That is it pretty much. You can find some more minor changes I did in the commit. They are mostly just removing remnants of HTMX we no longer needed.

We can now create a proper release build by stopping the watch and running npx shadow-cljs release client, or for the clj purists clj -A:client -M -m shadow.cljs.devtools.cli release client. This will get us a minified main.js which is ready for production use. I don’t know biff and I don’t know what it takes to get this into an actual deployment, but since it is just a singular main.js file I’d assume it isn’t too much work. The cljs-runtime dir created by the watch is not required for production use and can be deleted.

Conclusion

We added CLJS to an existing backend project and started writing the first pieces of functionality. Overall in about 10 lines of config and less than 100 lines of code. A good start and a good basis for more. Whether to use it over HTMX+hyperscript or not, is up to you. Again this is not about comparing these technologies in particular, just showing how to get started with minimal CLJS.

For the curious though the resulting main.js file is 152.16 KB and 35.25 KB when gzipped. I generated a build report, if you want more details. I’d say that is competitive to the previous ~153.4 KB and ~44.6 KB gzipped HTMX+hyperscript used.

Of course this comparison is not useful, but I hope to have convinced you at least a bit that CLJS frontend doesn’t need to be this heavy complex behemoth it is often portrayed as. I believe it is certainly something you can learn and get started with, especially if you are already writing CLJ anyway. Learning about the DOM and JS interop is much less daunting than it may seem. The main skill required is knowing where to look, I don’t know most of the browser APIs by heart either. You don’t need to go full react SPA to get something useful, but you absolutely can when it makes sense. The setup is identical, you take it wherever needed in the code.

I’m aware that my 25+ years of experience make me mostly blind to how complicated the code actually is, but I’m confident to say it is not an insurmountable hurdle that should make you abandon all hope and never try. I’d even say the time invested is worth more than investing time in less flexible “trendy frameworks” that might be replaced by something else in a few months/years, but I wanted to keep rants to a minimum. SCNR.

I’m aware that I’m also absolutely blind to the build tool “price”, I’m the shadow-cljs author after all. But I do believe that the config we have is reasonable, and the tool easy enough to run. Everything from development to full production is covered and done. The User’s Guide covers everything if you need something else, but I have projects where the config looks like this for years and never needs more or any changes at all for that matter.

Personal note: I’m sure it is possible to integrate all this with bb dev and skipping the extra terminal session, I didn’t bother since I actually prefer to keep things separate. I also actually prefer running npx shadow-cljs server (or clj -A:client -M -m shadow.cljs.devtools.cli server) and starting the build via the http://localhost:9630 web UI provided by shadow-cljs, which also has some other goodies for development. For this post it is simpler to just tell you about some basic commands, than instructing you to click on some unknown UI. What you use is up to you, and the end result is the same, only wanted to mention that this also exists.

Feel free to ask me anything about the subject. I’m usually around in the clojurians Slack (@thheller in #shadow-cljs and #clojurescript), the clojurescript stackoverflow tag, ClojureVerse or ask.clojure.org and try to answer as many questions as time permits.