CLJS and the React Compiler

React Compiler is a build-time optimizing compiler specifically designed for React. It has been in development for a while and is supposedly nearing completion and public release. I’m writing this post, so I have something I can link people to, that will probably come asking how to use it with shadow-cljs.

I have no insight into how this is going to work from a tooling perspective, but based on the public previews available, I have little hope that this will work for CLJS at all. It must process JS code written in a specific style, and CLJS output just isn’t that. It might work to some degree, but I wouldn’t hold my breath. But that is not a bad thing, because honestly, I think we have something much better: Macros!

Why is a Compiler useful at all?

The motivation behind React Compiler is that the user can worry less about hand-optimizing React code and let the compiler do that instead. Optimizing here is referring mostly to “memoization”, or said another way reducing the amount of code that needs to run on every render.

React Hooks were added to make writing components easier and more composable, by keeping everything in one function and not a class with specific lifecycle methods. That idea in itself is not bad, but necessitates something like React Compiler to gain back some performance that can otherwise be lost.

A simplified example from the CLJS perspective might be something like this, even without any hook at all:

(defn fancy-button [{:keys [label]}]
  [:button.oh-so-fancy label])

If the label value is unchanged from one render to the next there is technically nothing to be done, but when called this function will still construct a new vector with two elements. It gets even worse in a CLJS context, since React has no clue what Hiccup is. There is an extra translation step that needs to happen on every render to turn Hiccup into React elements. Which is the reason why some CLJS React wrappers skip Hiccup and use functions instead, or even macros. Regardless, with the React element at hand, React will then finally compare them and find that there is nothing to be done. Sounds rather wasteful, and if you do that thousands of times per render it adds up quickly to become somewhat expensive in real terms.

The idea behind the React Compiler is to memoize this automatically, just like React.useMemo would.

(defn fancy-button [{:keys [label]}]
  (react/useMemo
    (fn [] [:button.oh-so-fancy label])
    #js [label]))

The compiler actually does it a bit different, but the result is the same. If label is unchanged, it returns the exact same result, which is identical? (or === in JS terms) to the previous and allows React to skip the “diffing” work.

I’m simplifying a lot here. React already does things to avoid calling fancy-button in certain circumstances, but hope you get the concept nonetheless.

How do Macros help?

Macros are basically the exact same thing as the React Compiler. They allow the developer to write one style of code, but output something more optimized automatically.

JS doesn’t have macros, so it is constrained in what code a developer can reasonably write. Which is why React has Hooks in the first place. Prior to Hooks code was often spread between several places, and not very composable. Hooks fixed that by making them function calls with special semantics, that still look like regular function calls, at least in theory. I haven’t written any actual React code in many years, so I don’t know how well this works in practice, but the theory is sound.

A Practical Example

I’m not a React user, so the only example I can give is for code I have written for shadow-grove, which is my CLJS only solution in the same problem space as React.

However, the same ideas and concepts very much apply to CLJS+React as well. With macros, you can generate pretty much any code you want, while still making the code reasonable to write. I feel like macros are underused in this context, so let me present my take on it.

(defc example [foo bar]
  (bind baz (compute-expensive foo))
  (render
    [:div.example bar baz]))

defc is the component macro, which very much like defn ends up defining a component function we can call later. It has special bind and render forms, which is where most of the magic happens. There are others, but I’m trying to keep this post short.

I don’t want to bore you with implementation details, so let me show what would like in React (roughly):

(defn example [foo bar]
  (let [baz (compute-expensive foo)]
    (as-element [:div.example bar baz])))

On the surface this looks very similar, but this is only because you cannot see what defc actually generates as its output. So, this would be closer to what you actually get as the defc output. Again, this looks much different since it isn’t React based at all, but still this would be somewhat comparable.

(defn example [foo bar]
  (let [baz (react/useMemo #(compute-expensive foo) #js [foo])]
    (react/useMemo
      (fn []
        (as-element [:div.example bar baz]))
      #js [bar baz])))

Let me try to break this down a little. The assumption is that compute-expensive can be memoized and only needs to run if foo changes. The macro can infer that from the code, whereas this was manually specified in React (without its Compiler). Same for the returned element, it can be memoized since it is faster to check if bar and baz are identical than diffing a potentially large elements structure. I’m keeping that structure deliberately simple here, as I hope you can imagine a larger HTML structure there.

The above is still somewhat reasonable to follow, but not something I’d want to write by hand every day. Let’s just say this gets out of hand quick in real world application components, which is why React Compiler was started in the first place.

Conclusion

Macros let you write the actual component “DSL” without the constraints that React has as to having to write actual working JS code in the first place. That code can enforce semantics much better from the start. The generated code can be just as optimized as the React compiler output would be, the developer never needs to care.

This isn’t at all about defc and the code it generates. I’m not claiming that it is perfect in any way. In fact, I hope this inspires others to continue with this idea and applying it to CLJS+React in many different ways. Frameworks such as re-frame already do a lot to mitigate the need for hooks in the first place, but ultimately do not solve those mini-per-component optimizations.

I’m happy with defc and will likely write another post attempting to explain why it looks the way it does in detail.

Update: Actually wrote that defc doc.

11 Likes

appreciate that shadow-cljs and shadow-grove. but I think if we want it could go forward, three things would be important - Documentation, Tooling, and DX. Technical Excellent is always secondary and that’s why React wins the game. The ecosystem is also important, but it would grow afterward.

1 Like

I did not mean to make this post out to be about shadow-grove. For most projects it would just be unreasonable to use grove over react, given that it is written by one person in my spare time for my own use. It can never compete with the vast ecosystem that is react, and that is fine.

My challenge was to write a macro that makes component code “pleasant” to read/write, while producing the most optimized version of the code internally. Whether that emits react or whatever else is really secondary. Components mostly have the same concerns, independent of library used.

That being said, if anyone wants to do a deep dive and contribute to the grove docs, tooling, DX that would be great. There are limits in how much I can do on my own in my spare time and I build what I need when I need it.

Thank you for sharing and all the work :+1:

Do you know whether UIx - which to my knowledge is a macro based React wrapper - does such optimizations? If not, I guess it can do them in principle, right?

UIx does use macros, but as far as I’m aware only to the extent of optimizing Hiccup->React element conversion as much as possible. I do not think it does any auto-memoization like I described.

Uix has defui for defining component which is a macro, which should be a good place for this kind of optimization, however, Uix aims as a thin wrapper, and might not want to be different from the raw react. (so actually, doing it or not only depends on efforts and how to define raw react mean)

That’s part of what I mean, building a react-like library from the ground up and keeping the promise for a long time might be too much workload for a single developer or even for a small community like CLJS.

But we can foresee, that the introduction of React Compiler, in the long term, will make the current wrappers pick up React updates harder and harder.

… will make the current wrappers pick up React updates harder and harder.

Yep, thats why I suggested looking into macros, so that React Compiler isn’t even a question. I’m very confident that it can output something very close to the React Compiler output.

I wrote shadow-grove because I think that react in itself isn’t super great. I don’t mind losing access to its ecosystem. For other people that might be different.

The React wrapper Helix does two parts of this:

  • The hook macros have an option :auto-deps that finds the relevant bindings/variables in an expression, using CLJS analyzer
  • Then using metadata optimizations (experimental option) the hooks and auto-deps can be applied to individual expressions marked eg ^:memo [foo "bar"]

So these are still manually applied, but it solves the issue of manually tracking dependencies when going to add the optimizations.

The library also has several other equivalents of JS tools that React normally has in external linter/bundler, detecting invalid patterns and tracking ‘signatures’ for fast refresh.

I finished writing the defc doc I mentioned for the curious among you.