Reagent - Towards React 18
React 18 has been out since March this year, but Reagent still needs to be updated to use it by default. Usually, it isn't too big a hassle to update to the latest React version, as they are pretty good about keeping backward compatibility. React 18 is also backward compatible, but with a caveat:
If your app uses the old
ReactDOM.render API to render your component tree
into DOM, React will use a compatibility mode, and the new features won't be
Because Reagent isn't only a lightweight wrapper, but includes plenty of its own features (RAtoms, render batching, elements as Hiccup style data), we have a comprehensive test suite, measuring in at around 3700 lines, 140 deftest forms, and 2400 assertions. Some test cases will also run against Reagents class and function based React component implementations. The entire test suite runs against different environments:
- React from npm or Cljsjs package
- Browser and Node.js
- Shadow-CLJS and regular ClojureScript compiler
- With and without Closure optimizations
Reagent rendering tests come in two types:
- Simple tests taking a Hiccup style element form and rendering them to a string to validate the output
- Stateful tests mounting a component to DOM, running checks, updating a RAtom, and checking the DOM reflects the update.
Due to Reagent's Async rendering batching, these stateful tests need to consider
that it takes some time for DOM to reflect the RAtom update. The simplest way
for the tests to work around this is by using
reagent.core/flush, which takes
any updates in the Reagent's queue and triggers React component updates
immediately. Thus far, the React render operation has been synchronous, so
flush, the DOM would reflect the latest Reagent state.
Another way for the tests is to use the
reagent.core/after-render callback to
wait asynchronously for DOM to be updated. The tests use this approach
sparingly as it makes reading the code harder.
In version 18, React itself got a similar update batching mechanism. Reagent will first queue the updates, then trigger a React update which React will now queue, and after React flushes the queue, DOM is updated. This should happen so fast that users don't see the batching.
For tests, this means that
reagent.core/flush is no longer enough to force
the DOM to update immediately. As this is a problem for tests in React
applications also, React provides an
act function in the test-utilities
package. The function will force an immediate DOM update for synchronous
updates or return a Promise for async updates, and the test code can wait on
the Promise for DOM updates to be visible.
The problem with
act is that it is only available with React development
bundles, and we want to also run Reagent tests with optimized builds, which
will, by default, use React production bundles with both Cljsjs and npm
Another way would be to use the ReactDOM
flushSync function to trigger React
updates without batching. The problem with this is that it would need to be
used in the Reagent's internals to trigger the React update when flushing the
For now, in the Reagent master branch, the old tests are using
ReactDOM.render, and there is a single new test case using
just checking the DOM state after a 16ms timeout.
As both Reagent and React are now batching the updates, it might be that after a state update, it would now take up to two frames to update the DOM, as Reagent will queue the update, flush it to the React in the next animation frame callback, and then React will do the same.
However, I didn't find this to be the case in my testing:
|Latency, 100 updates, average||useState||RAtom|
We get a baseline update latency value using a React
useState hook, which doesn't use
the Reagent queue for updates. For both the old mode and
createRoot, it seems
like RAtom updates have a one-millisecond overhead, which is less than one
createRoot version, the latency for both cases seems to be much less
than half of the animation frame time. There seems to be more at work with
React batching than just waiting for the
Hopefully, it will be possible to remove the batching from Reagent and trust React here. Again, the most work here is keeping the Reagent test suite working, not removing the batching code per se.
In addition to getting the Reagent test suite working and testing Reagent with
createRoot mode, there is also the question of how to expose the new
API to the users. In theory,
reagent.dom/render could use the new
createRoot behind the scenes and hide the change from the users. But I've
already seen that trying to hide React API will lead to extra complexity in
Reagent, so I will probably keep
reagent.dom as-is and introduce a new
reagent.dom.client namespace matching the React.
Users will be encouraged to move to the new API by React warning
ReactDOM.render use, and Reagent could also mark the
Another change is that in React 18, e.g., the
createEffect hook callback should
only return either a destroy callback function or JS
undefined value. This
change is somewhat inconvenient in Cljs, where a function would often return
nil or other unused value. Perhaps this is a good time to add a
namespace for helpers, which would take care of such inconveniences.
I will continue working on running more of the Reagent test suite with
createRoot before the next version is released.
P.S. If you are interested in a more lightweight Hooks-first React wrapper for ClojureScript, check UIx2.