Immediate-Mode UI with Reactive State for the Web

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.

Contents and TL;DR

Background on Immediate-Mode UI

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

Example

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.

Programmers Manage UI State

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.

IMUI for the Web

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.

IMUI Already Manages Component State

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.

Unique Component IDs for State Management

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.

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

Similarity to Signals

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.

Private State

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.

Making UI Components Reactive

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.

How button.clicked Works

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

Everything is Reactive State

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.

Reactive Props Enable Caching

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.

Summary

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.

Try it out

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