Declarative Setup, Imperative Update: The Key to High-Performance UI Components in React
Michael
This is the second post in a two part series on performant React state management
- Benchmarking Preact Signals versus the React alternatives
- Declarative Setup, Imperative Update: The Key to High-Performance UI Components
For UI components that need to be updated rapidly, I'm a fan of the "build with the vDOM, update directly with events" pattern. It provides both optimal developer experience and runtime performance. Complex user interfaces can be modelled with a high level declarative syntax, then specific attention can be paid to performance sensitive areas.
Preact released a state management library called Signals which allows for performant, fine-grained re-renders, in some cases bypassing the virtual dom entirely. While they achieve this vision quite well in native Preact, the story isn't as nice in the land of React. Preact Signals has a package that provides React bindings, but it monkey patches React.createElement
which I don't love. It also introduces some magic to enable implicit subscription to state, which violates expectations surrounding React's state management. I prefer my magic to happen inside a component instead of spread out everywhere so I can keep a better eye on it.
In this post we'll go through how you can use the underlying concepts of 'Declarative Setup, Imperative Update' to build your own components in React which have better performance characteristics, without monkey patching or introducing magic. These components can use any state management library you like and are explicit about the optimisations which will occur.
As a teaser, here's a couple of the results of our benchmarks. A Zustand based state management solution can imperatively update our text with comparable performance to SolidJS and native optimised Preact Signals. We can even use this method to use Preact Signals in React without monkey patching.
Writing our own vDOM bypassing component
Anything that provides some kind of 'subscribable' API can be used to update a specific DOM node without invoking a re-render. This includes state management libraries like Zustand or Observable utilities like RxJS. You can even connect Preact Signals without using the provided React monkey patching package, just subscribe in an effect()
call!
Our component has a couple refs, to an outer span
element, and to an inner Text
node. A useLayoutEffect
hook is used to build and attach a Text
node to the span. The Text
node is completely controlled by us instead of React. A regular useEffect
hook is used to subscribe to our 'emitter' of choice. On changes, we check if our ref is still active, and set the nodeValue
of the Text
node directly. On unmount, we unsubscribe from the emitter.
type Unsub = () => voidtype Emitter = { subscribe: (value: string) => Unsub getInitialValue: () => string}const MagicComponent = (props: { emitter: Emitter }) => { const spanRef = useRef<HTMLSpanElement>(null) const textRef = useRef<Text | null>(null) // Create our text node useLayoutEffect(() => { // If your particular emitter implementation allows an initial value, // grab it synchronously here. If not, provide a default. const textNode = document.createTextNode(props.emitter.getInitialValue()) // The spanRef will have been created by now. // Grab a reference to it, it will be used in the unmount closure. const currentSpanRef = spanRef.current! // Append the textNode to the span. currentSpanRef.appendChild(textNode) // Update our ref of the textNode textRef.current = textNode // When the component unmounts, remove the child return () => { currentSpanRef.removeChild(textNode) } }, [props.emitter, spanRef, textRef]) // Subscribe to our emitter useEffect(() => { const unsub = props.emitter.subscribe((value: string) => { if (textRef.current) { textRef.current.nodeValue = value } }) return () => { unsub() } }, [props.emitter, textRef]) // Get React to render us a span element return <span ref={spanRef} />}
While this example uses text, it can be done with any HTML attribute for any element that provides a ref. You can modify the nodeValue
setter to apply any transformation you like, just make sure not to tread on React's toes. Don't remove remove an element React created for example; if you do, when the component unmounts, React will throw an error.
While this is a lot of code to write to update a single piece of text, it can be written once and reused, encapsulated in its component. There's no monkey-patching, no overriding of React's internals. It could be easily refactored into a hook based on your specific situation, where you pass it a ref, something subscribable, and a callback to run on new data. However in this specific text situation, I think a component is more idiomatic.
Use cases
In Electric UI, our hardware-focused user interface library, we have a system called DataFlow. It is a real-time stream processor, intended for transforming data from hardware. This data is usually piped to WebGL backed charts, but 'aggregation' functions are frequently displayed as text next to the chart. We use a component similar to the above to provide fast updates without invoking a React re-render every frame. Similarly when mousing over the chart, a DOM element is created and its position and contents are updated with this method. Within the WebGL component, react-three-fiber is used to build the scene graph, then everything is updated imperatively. Here are some other use cases where this technique can be used:
- A tick-mark on the side of a chart has its vertical position, value and colour all updated in real-time.
- A colour picker has its background, colour, position all updated as the mouse interacts.
- A slider has its handle position and tick mark colours update as the mouse moves.
- A text box in a form is 'controlled', updating the text value and running validation logic on every keypress, but only triggering form re-renders when an error is detected.
The downside of this approach is that it isn't generalisable at runtime. This won't help render a list of thousands of elements faster, or a table of elements, or any kind of complex UI. It's a specific optimisation for when you want to update specific things rapidly.
This kind of imperative API is how libraries like react-spring can achieve such great performance. The animated
components build up their DOM with a regular render, then their ref is kept for imperative handling by react-spring.
Benchmarks
A Text
node was constructed and updated in various methods to form this benchmark.
The results are as follows:
The benchmarks were constructed from these methods:
-
A VanillaJS, regular
Text
node with itsnodeValue
property directly updated. This represents the lower bound for possible work done. -
Preact's signals, used in Preact, using the 'optimised' path (signal directly rendered in component).
-
React with a ref, updated with a callback in a class. "Minimum overhead to use a ref".
-
React with a Zustand store, subscribed to in a
useEffect
hook, updating a refsnodeValue
. -
React with Preact signals, subscribed to via the
effect
method, updating a refsnodeValue
, without monkey patching. -
React with RxJS, subscribed to in a
useEffect
hook, updating a refsnodeValue
. -
React with ElectricUIs DataFlow, using the standard
DataSourcePrinter
component. -
SolidJS with an idiomatic signal.
-
Preact functional component, updated externally via a
useState
hook. -
Preact signal, unoptimised (signal value accessed in component).
-
Preact class component, updated externally via an emitter and a
setState
call. -
React functional component, updated externally via a
useState
hook. -
React using the Preact Signals library, subscribed to in
useEffect
, updating auseState
hook. -
React using RxJS, subscribed to in
useEffect
, updating auseState
hook. -
React class component, updated externally via an emitter and a
setState
call. -
React using Zustand with the provided hook.
-
React using the monkey-patched Preact Signals library.
More detailed benchmarking methodology is attached at the end of the previous post.
Benchmark Discussion
Ref based imperative updates do minimal work on top of baseline, as a result they achieve comparable performance to native optimised Preact Signals and SolidJS, with only a slight overhead over VanillaJS direct nodeValue
setters. They achieve an order of magnitude performance improvement compared to their 'regular render' counterparts.
Preact's Virtual DOM diffing implementation is impressively performant, however the monkey patched React library performs worst out of all tested solutions which is surprising given the maintenance risk it introduces.
I'm delighted Electric UI's DataFlow performs as well as it does, given that it's optimised for batches of thousands of graph points in a single call, delivered to the GPU, rather than singular strings of text delivered to the DOM.
When you need to update a specific piece of text quickly, update a css attribute, or position an element based on the mouse position, perhaps this kind of component can be a good fit.