Reagent - Towards React 18
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 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 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 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

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 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.