Marius Nita
In this article, we describe how Hyperdiv adapts Immediate-Mode UI for web development by enhancing it with reactive state similar to Signals. This design enjoys the minimal and declarative syntax of Immediate-Mode UI, while re-rendering the UI on demand, only when its state dependencies change.
Background on Immediate-Mode UI
Immediate-Mode UI (IMUI) frameworks offload UI state management to the framework user, and sync the UI to the state by rendering continuously at a high frame rate.
IMUI for the Web
Rendering continuously at a high frame rate does not align with expectations in web development. We want to re-render only when necessary.
IMUI Already Manages Component State
Despite offloading state management to users, IMUI frameworks also manage some component state internally. A core aspect of IMUI is assigning stable IDs to components, in order to manage this internal state.
Adding Reactive State
Hyperdiv embraces the existing state management of IMUI and extends it with user-facing reactive state similar to Signals. With this addition, the app re-renders only when dependent state changes.
Making UI Components Reactive
In additional to adding user-defined reactive state, built-in UI components are made stateful and reactive. All Hyperdiv components, including built-in UI components and user-defined state, are based on the same reactive state architecture.
Reactive State Enables Caching
We add a @cached
decorator that avoids re-running function calls
whose read dependencies have not changed.
Summary
Immediate-Mode UI (IMUI) is a paradigm for building user interfaces that has been popular in the game dev community. Two of the most popular IMUI frameworks are Dear ImGUI and Nuklear for C++/C.
In IMUI, you provide a function that (a) declaratively draws components and (b) simultaneously checks if events fired on those components, and handles those events in-line. The framework, in principle, will then run that function continuously and redraw the UI on every run. (How exactly this is done varies.)
A program in a hypothetical IMUI framework for Python might look like this:
from imui_framework import checkbox, text, run
checked = False
def main():
global checked
checked = checkbox("Check Me", checked)
if checked:
text("It is checked!")
run(main)
This program displays a UI with a checkbox. When the checkbox is checked, the text "It is checked!" is also displayed under the checkbox.
In principle, the framework calls main()
continuously in
a loop. Within each call to main()
, the call
checkbox("Check Me", checked)
will render a checkbox
whose checked state is determined by the parameter
checked
. In frames in which the checkbox is not toggled
by the user of the UI, the call will simply return the
value of the parameter checked
. When the UI user toggles
the checkbox, the call will return (not checked)
,
toggling the value of the checked
parameter.
It is then up to the user to save this toggled state
(in the checked
variable in this case), and then
feed that toggled state back into the call on
subsequent frames, therefore causing the checkbox to
continue being rendered in the same state, until
toggled again.
In the example above, the IMUI programmer has to
manage the checked
state of the checkbox. On any
given frame, the framework internally does not know
whether that checkbox is currently checked; instead,
the user has to maintain and provide that state to the
framework. The framework is responsible for drawing
components and communicating UI events on those
components back to the user.
This philosophy of "the user manages the UI state" is prevalent in the IMUI space. It has the significant advantage that users can structure, serialize, and persist their UI state however they want.
We love IMUI's extremely terse and declarative syntax, and want to adapt IMUI to Web app development.
Running main()
continuously, at a high frame rate, is
essential to the IMUI design philosophy where programmers
manage the UI state. Running the function continuously
naturally "syncs" the UI to the current UI state. Running
main()
continuously is also a natural fit for in-game
UIs. You just render your UI inside the game loop!
But running the application function at a high frame rate
is impractical for web applications. In a web context, we
want to re-run main()
only when it's likely that it
would render a changed UI.
Ultimately, we want to know if a dependent state variable
changed. As long as the state remains unchanged, there is
no reason to re-run main()
. We would like to track reads
and writes to state variables, and re-run main()
only
when a previously read state variable was written.
Intercepting reads and writes to variables of which the framework is unaware is impossible, short of a precise static analysis and source to source translation that instruments all the reads and writes.
Instead, we will break with the IMUI philosophy where users manage all the state. We will borrow ideas from Signals (also see S.js and Preact Signals) to design a managed state data structure that intercepts reads and writes and re-renders the UI only when previously read state is updated.
But first, we look at how IMUI frameworks already manage component state under the hood.
While IMUI state management philosophy is to offload UI state management to the user, this isn't entirely the case. The user manages the "high level" component state that logically makes sense for users to be concerned with. Like the checked state of a checkbox or the selected value of a radio group.
The framework then manages the rest of the component state, that would be cumbersome and nonsensical for users to have to manage.
One example of such internal state, in game context, may be ephemeral animations. When you click a button, the button may become visually highlighted using a short animation. If the app runs at 60 frames per second, the animation will need to play out across many frames. To properly render the current state of the animation on each frame, the framework needs to know whether that button is in "clicked animation" state, and how much time has elapsed since the click. It does this by managing component state internally.
A core characteristic of IMUI frameworks is that they require a unique component ID for each UI component, that is stable across all UI re-renders. They may require programmers to pass in these IDs manually to each component constructor, or they may disburden programmers from this chore by performing various tricks to infer most IDs automatically.
The reason IMUI frameworks need these IDs is precisely because they manage component state under the hood. They maintain a mapping from component ID to internal component state.
We've established that an IMUI framework has to manage component state, and IMUI frameworks employ various techniques for assigning unique, stable IDs to each UI component, in order to manage that state.
Hyperdiv embraces these existing architectural
concepts and repurposes them for a new state
component that is user-facing, reactive, and
managed. The attributes of this state component (we
call them props) work similarly to Signals.
This is what using state
looks like:
import hyperdiv as hd
def main():
state = hd.state(count=0)
hd.text(state.count)
if hd.button("Increment").clicked:
state.count += 1
hd.run(main)
Ignoring for now how button.clicked
works, the state
component works like this:
hd.state(count=0)
creates a state component with a
unique component ID. Internally, the framework maintains
state like {"<component_id>": {"count": 0}}
.
When state.count
is read by the line of code
hd.text(state.count)
, a read dependency is
registered on ("<component_id>", "count")
.
When state.count
is mutated in response to the click,
that read dependency is invalidated and main()
is
scheduled to re-run.
The framework remembers all the read dependencies registered in the previous run of the app function. The framework then listens for mutations, and re-runs the app function only when a mutation invalidates an existing read dependency.
Hyperdiv reactive props work like signals in the sense that reading them registers a read dependency on an enclosing function call, and writing them may cause a re-run of a dependent function call, if that function call previously read that prop. Signals support other features that do not apply to Hyperdiv.
Notice that this design enables UI functions to naturally define local, private state:
def counter():
state = hd.state(count=0)
hd.text(state.count)
if hd.button("Increment").clicked:
state.count += 1
def main():
counter()
counter()
We define a counter
component with private count
state. Then we can add multiple counters to the app,
each with private, independent state. This stands in
contrast to vanilla IMUI, where we may have to manage
multiple global count
variables, one per counter.
In the design outlined above, state
components start to
look very similar to IMUI UI components like buttons and
checkboxes: They are components with unique IDs, and the
framework tracks a component's state internally based on
its unique ID.
Based on this insight, we make all component state
reactive. Instead of the user managing most UI component
state, like the checked
state of a checkbox, and passing
that state in and out of calls to checkbox()
, the
framework manages that UI state in built-in reactive
props.
So checked
becomes a built-in reactive prop of checkbox
:
def main():
checkbox = hd.checkbox("Check Me")
if checkbox.checked:
hd.text("It is checked.")
else:
hd.text("It is not checked.")
When this app runs, a read dependency is registered on the
checkbox's checked
prop. When a user of the UI toggles
the checkbox, the checked
prop is mutated, invalidating
the dependency and causingmain()
to re-run, which
outputs an updated UI.
button.clicked
WorksProps like button.clicked
are reactive props just like
checkbox.checked
. When an app executes code like if button.clicked:
, a read dependency is registered on that
prop. When the button is clicked, clicked
is mutated to
True
, the read dependency is invalidated, and main()
re-runs.
"Event props" like clicked
are special in that they are
automatically reset by the framework, back to their
default value (False
in the case of clicked
), at the
end of the run in which they were True
.
At this point, all components, whether a UI component like
checkbox
or a custom state component like state
, work
virtually identically. They are "groups of reactive
props". The core difference is that UI components are
rendered in the UI whereas state
components are ignored
for rendering.
Hyperdiv exposes a single base class, which handles the
defining, reading, and writing logic of reactive
props, and all components, including state
, derive
from that base class.
Since we are tracking read dependencies and write
invalidations on all props, we can now add a @cached
decorator that, in addition to caching the return
value of a call, it also caches the Hyperdiv UI
(virtual DOM) generated by that call, as well as the
read dependencies recorded during that call.
When main()
re-runs, calls to @cached
-decorated
functions will re-run only when one of the internal read
dependencies of that call is invalidated. Otherwise, the
cached virtual DOM and return value are reused.
@hd.cached
def counter():
state = hd.state(count=0)
hd.text(state.count)
if hd.button("Increment").clicked:
state.count += 1
def main():
counter()
counter()
In this example, when we click one of the buttons, only
the corresponding call to counter()
re-runs. The other
call to counter()
reuses its cached DOM from the
previous run.
Immediate-Mode UI provides an attractive, terse syntax for expressing UIs declaratively. It promotes a linear workflow for building UIs quickly with minimal plumbing and boilerplate.
Traditionally, UI state is managed by the user of the framework in data structures that are not visible to the framework. Changes to UI state are picked up by running the application function at a high frame rate.
To adapt the Immediate-Mode paradigm to the Web, Hyperdiv extends state-management concepts already present in IMUI frameworks with signals-like reactive state.
Hyperdiv departs from the traditional IMUI philosophy to offload UI state management to the user, gaining on-demand rendering in exchange.
Install Hyperdiv and open the documentation app locally:
> pip install hyperdiv
> hyperdiv docs
Hyperdiv requires Python 3.9+ and has been tested on macOS and Linux.
Star on GitHub