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

  1. Benchmarking Preact Signals versus the React alternatives
  2. 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.

Plot of subset of methods

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 = () => void
type 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:

Plot of all methods

The benchmarks were constructed from these methods:

  • A VanillaJS, regular Text node with its nodeValue 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 refs nodeValue.

  • React with Preact signals, subscribed to via the effect method, updating a refs nodeValue, without monkey patching.

  • React with RxJS, subscribed to in a useEffect hook, updating a refs nodeValue.

  • 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 a useState hook.

  • React using RxJS, subscribed to in useEffect, updating a useState 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.