Applying the Art of CLJS Frontend
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.