Also titled “Lesson learned in CLJS Frontends” …

I have been doing frontend development for well over two decades now, and over that time I have used a lot of different frontend solutions. For over a decade I have been into ClojureScript and I don’t think I’m ever moving on from that. The language is just beautiful and the fact that I can use the same language on the server is just perfect. I’ll only talk about CLJS here though, since the frontend just runs JS, which we compile to.

I like optimizing things, so I’ll frequently try new things when I encounter new ideas. One the most impactful was when react came onto the scene. Given its current state I obviously wasn’t the only one. I’d say in general the CLJS community adopted it quite widely. The promise of having the UI represented as a function of your state was a beautiful idea. (render state) is just too nice to not like. However, we very quickly learned that this doesn’t scale beyond toy examples. The VDOM-representation this render call returns needs to be “diffed”, as in compared to the previous version whenever any change is made. This becomes expensive computationally very quickly.

In this post I kinda want to document what I (and others) have learned over the years, and where my own journey currently stands.

What The Heck Just Happened?

Answering this question becomes the essential question the rendering library of choice has to answer. It only sees two snapshots and is supposed to find the needle in a possibly large pile of data. So it has to ask and answer this a lot.

Say you have an input DOM element and its value should be directly written into some other div. The most straightforward way is to do this directly in JS:

const input = document.getElementById("input");
const target = document.getElementById("target");

input.addEventListener("input", function(e) {
   target.textContent = input.value;
});

Plus a <input type="text" id="input"> and <div id="target"></div> somewhere. I’ll call this the baseline. The most direct way to get the result we want. There is not a single “What the Heck Just Happened?” asked, the code is doing exactly what it needs in as little work was possible.

Of course, frontend development is never this simple and this style of imperative code often leads to very entangled hard to maintain messes. Thus enter react, or any library of that kind, that instead has you write a declarative representation of the current desired UI state. I’ll use CLJS and hiccup from here. The actual rendering library used is almost irrelevant to the rest of this post. The problems are inherent in the approach.

So, for CLJS this might look something like:

(defn render [state]
  [:<>
   [:input {:type "text" :on-input handle-input}]
   [:div (:text state)]])

I’ll very conveniently ignore where state comes from or how its handled. Just assume it is a CLJS map and the handle-input is a function adds the current input value under that :text key and whatever mechanism is used just calls render again with the updated map.

So, what the rendering library of choice now has to decipher is two different snapshots in time.

Before:

[:<>
 [:input {:type "text" :on-input handle-input}]
 [:div "foo"]]

After:

[:<>
 [:input {:type "text" :on-input handle-input}]
 [:div "foo!"]]

It has to traverse these two snapshots and find the difference. So, for this dumb example it has to check 3 vectors, 2 keywords and a map with 2 key value pairs and our actually changed String. We basically went from 1 operation in the pure JS example to I don’t even know how many. The = operation in CLJS is well-defined and pretty fast, but actually often more than one operation. For simplicity’s sake lets say 1 though. So, in total we are now at 12 times asking “What The Heck Just Happened?”.

Point being that this very quickly gets out of hand. I’m going to assume there are going to be hundreds or even thousands of DOM elements. An easy way to do a count how many DOM elements your favorite “webapp” has is running document.body.querySelectorAll("*").length in the devtools console. I did this for my shadow-cljs github repo and get 2214 in incognito. For reasons I can’t even see it goes up to 2647 when logged in. Amazon Prime Video Homepage is 5517 for me. You see how quickly this goes up. Of course not every app is going to have that many elements, but it also isn’t uncommon.

The cost isn’t only the “diffing”. You also have to create that Hiccup in the first place. Please note that it is pretty much irrelevant which what representation is used. React Elements have to do the same work. However, for libraries that convert from hiccup to react at runtime (e.g. reagent) you also have to pay the conversion cost. Hence, why there are many libraries that try to do much of this at compile time via macros.

Death by a thousand cuts. This all adds up to become significant.

Enter The Trade-Offs

Staying within the pure (render state) model sure would be nice, but if you ask me it just doesn’t scale. Diffing thousands of elements to “find” one change is just bonkers. Modern Hardware is insanely fast, but I’d rather not waste all that power.

I’m by no means saying that everyone should always render everything on the client. Sometimes it is just best to have the server spit out some HTML and be done with it. Can’t be faster than no diff ever right? Well, we want some dynamic content, so we have to get there somehow. I covered strategies for dealing with server-side content in my previous series (1, 2, 3).

This is all about frontend and pretty much SPA territory, where this kinda of scaling begins to matter. We can employ various techniques to speed the client up significantly.

Memoization

One of the simplest and also most impactful things is making sure things stay identical? (or === in JS terms). That is the beauty of the CLJS datastructures. If they are identical? we know they didn’t change. JS objects not so much, but I won’t go into that here.

So, modifying the example above we can just extract the input element, since it never changes.

(def input
  [:input {:type "text" :on-input handle-input}])
   
(defn render [state]
  [:<>
   input
   [:div (:text state)]])

Our rendering library of choice now finds an identical? hiccup vector, whereas before it had to do a full = check. This removes 6 “WTHJH?” questions. Quite a significant drop. Again, probably not the exact number, but I hope you get the idea.

This technique is called memoization, where you have a function that returns an identical result when called with the same arguments. The goal being to reduce the amount of things we have to compare. Often just comparing a few values instead of the expanded “virtual tree”, i.e. avoid running as much code as possible. I skipped the function part here to make that example easier to follow. Same principle though.

Components

Components are basically the next level of memoization. Let’s say we turn our input into a “component”, using a simplistic CLJS defn for now.

(defn input []
  [:input {:type "text" :on-input handle-input}])
   
(defn render [state]
  [:<>
   [input]
   [:div (:text state)]])

So, our rendering library of choice will now encounter a hiccup vector with a function as its first element. On the first render run it’ll just call that and get the expanded virtual tree. On subsequent runs it can check whether that fn is identical? as well, and since it doesn’t take any extra arguments can just skip calling it completely. Again bypassing “a lot of work”.

There is a lot more to components of course, but I first want to highlight one problem they do not solve. render received the state argument. Let’s assume that for some reason input needs it as well.

(defn render [state]
  [:<>
   [input state]
   [:div (:text state)]])

Here the rendering lib will call (input state) basically. But it has no clue what part of state is actually used by input, it only knows that state changed, so it has to call input even though it still might expand to the exact same tree. Even the input “component” needs additional tools and care to avoid just generating the hiccup from scratch. Hence, in react terms to get the optimal performance you are supposed to useMemo a lot. Which isn’t exactly “fun” and very far away from our (render state) ideals.

We could change it so that we only pass the needed data to input, but that requires knowledge of the implementation, and that isn’t always straightforward.

Trying To Find Balance

In the end the real goal is finding a good balance between developer productivity/convenience and runtime performance. A solution that nobody can work on is no good, but neither is something that is unusably slow for the end user. Do not underestimate how slow things can get, especially if you measure against the not very-latest high-tech devices. Try 4x Slowdown in Chrome and the results may shock you.

My Journey

I have been working on my own rendering lib for a rather long time now. I have never documented or even talked about it much. Quite honestly I wrote this for myself and I consider this an active journey. It works and is good enough for my current needs, but far from finished.

One Conclusion I have come to is that the “push” model of just passing the whole state into the root doesn’t scale. Instead, it is better to have each component pull the data it needs from some well-defined datasource. And then have that datasource notify the component if the data it requested was changed. Then allowing the component to kick off an update from its place in the tree, instead of from the root.

Another superpower CLJS has is macros. So, leveraging those more can give substantial gains. I have the fragment macro (<<), which can statically analyze the hiccup structure and not only bypass the hiccup creation completely. It can also just generate the code to directly apply the update.

Changing our render to

(defn render [state]
  (<< [:input {:type "text" :on-input handle-input}])
      [:div (:text state)]]))

This still looks very hiccup-ish, even though it doesn’t create hiccup at all. But during macro expansion this is very trivial to tell that only (:text state) can possibly change here. This gets very close the initial 1 operation JS example. Heck if you help the macro a little more it becomes that literal 1 operation.

(defn render [{:keys [^string text] :as state}]
  (<< [:input {:type "text" :on-input handle-input}])
      [:div text]]))

But that level of optimization is really unnecessary. It sure was fun to build the implementation though. For the people that care, the trick here is that the update impl can just set the .textContent property if it can determine that all children are “strings”. So, it even works for stuff like [:div "Hello, " text "!"].

Also, an optimization what the React Compiler only wishes it could achieve. Not that I have any hope that it would ever understand CLJS code to begin with.

Conclusion

The point is that there are so many optimizations to find, that the challenge isn’t finding them. The challenge is building a coherent “framework” that can scale from tiny to very large, all while writing “beautiful” code.

This post is already getting too long, so I intend to write more detailed posts about my experiences later. I don’t even want to talk about my specific library. Most of the stuff I built I didn’t come up with in the first place. They are just “Lessons learned” by the greater community over the years. All of those techniques have been done before, I just adapted them to CLJS. A lot of them you could translate 1:1 and use them with react or whatever else.

Some of them however you cannot. The fragment macro could be adapted for react, but it could never reach the level of performance it has in shadow-grove because react can only do the “virtual DOM diffing” and is not extensible in that area. shadow-grove doesn’t have a “virtual DOM”, as in there is no 1:1 mapping of virtual and actual DOM elements. A fragment may handle many elements, like it did in the render example above.

There is this sentiment that due to LLMs having seen so much react code, that there is no value in building something that is not react. I think that is utter nonsense. There is however the argument of “ecosystem value”, where react clearly wins and is very hard to compete with. You kinda have to be a maniac like me and accept that you have to write your own implementation for stuff a lot, instead of just using readily available 3rd party libraries.

Again, all I wish to highlight with this post, and all my posts really, is that we should talk about Trade-Offs more. A lot of stuff on the Internet just talks about why they are good, and never mentions what it will “cost” you.

I’m very aware that my Trade-Offs would not be correct for most people, but everyone still should be aware of the ones they are making by committing to Solution X. Talking about them might get us close to a coherent story for CLJS, rather than the fragmented react-heavy landscape it currently is.

Building on top of something built by a part-time hobby enthusiast isn’t a good business strategy, so I really do not want this to be about shadow-grove, rather some of the ideas behind it and how we could maybe apply them more broadly.

For the next post I hope to add some actual numbers and benchmarks to highlight the problems I described with actual evidence. Probably starting with something react based and maybe some various optimization stages of that. That’ll take some time though.