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.