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
enabled.
Reagent Test SuiteLink to Reagent Test Suite
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
after 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.
React Update BatchingLink to React Update Batching
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
packages.
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
Reagent queue.
For now, in the Reagent master branch, the old tests are using
ReactDOM.render
, and there is a single new test case using createRoot
, and
just checking the DOM state after a 16ms timeout.
Double BatchingLink to Double Batching
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 |
---|---|---|
ReactDOM.render | 5.05ms | 6.07ms |
createRoot | 1.55ms | 2.72ms |
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
additional frame.
With the 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 requestAnimationFrame
callback.
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.
The New APILink to The New API
In addition to getting the Reagent test suite working and testing Reagent with
the new 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
about ReactDOM.render
use, and Reagent could also mark the render
function
deprecated.
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 reagent.hooks
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.