Guide / Reinhardt Reinhardt 0.4.0-alpha.13; moonc 0.10.11+6ff76a5f9

How to make a reactive UI in your favorite language?

In this guide

We'll study how to make a reactive UI in your favorite language. In this video, we'll use MoonBit as an example to reproduce Reinhardt's dependency-driven updates through WebAssembly. We build a small working core; the data structures and behavior checks below describe what to preserve when adapting it to your language.

Download the Rust and MoonBit experiments. Extract the ZIP and follow framework-internals/README.md. Tested with Rust 1.96.0, Moon CLI 0.1.20260827, moonc 0.10.11+6ff76a5f9, Node 22.22.1, Python 3.12 and a native C toolchain. Initial builds fetch dependencies. Both SQLite examples use separate in-memory databases; no server is needed.

rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.126 --locked
python3 verify.py --wasm
python3 -m http.server 8927 --bind 127.0.0.1 --directory wasm-ui

Open http://127.0.0.1:8927/ in a Wasm GC browser and click Run comparison checks. The Node ABI check and actual browser comparison establish different boundaries.

Two runtimes, one visible result

You click once, change a counter three times, and want the display to show the final value. The Reinhardt and MoonBit panels both reach three with one additional text Effect run. We will follow Reinhardt's real Rust reactive runtime, then reproduce that update path in MoonBit and WebAssembly.

This investigates a mounted counter and its dependencies, not a complete React implementation. Both modules run in the browser, and the comparison checks the actual nodes they update.

A read records who needs an update

Start with a Signal that stores the count and an Effect that writes it into a Text node. An Effect is a callback whose reads determine which future changes should run it again. While the callback executes, a tracked Signal read records the current observer as a dependency.

A later write can therefore select the affected callback without comparing a whole virtual tree. The Effect runs initially to produce the first display, then runs again when the runtime processes a notification. This is the part of Reinhardt's architecture that our small reproduction follows.

An untracked read answers what the value is now without subscribing the callback to later changes. Replacing tracked reads therefore changes behavior, even when the returned value looks identical.

Use the actual Pages binding

The Rust browser creates a reactive scope and keeps it alive for the mounted counter. It uses Reinhardt Pages to bind the count Signal to a data attribute on the element. The framework method creates an Effect that reads the Signal and sets that attribute.

Our sample adds a text Effect that changes the data of an existing Text node. Its counter measures that text callback only, not every framework Effect or the browser's paints. The source makes these operations visible, so we can give the MoonBit version the same expected observations.

Excerpt from the executable source; the complete program is in the download.

let count = scope.enter(|| {
    let count = Signal::new(0);
    Element::new(root).set_reactive_attribute("data-count", count);
    let runs = text_runs.clone();
    Effect::new(move || {
        text.set_data(&count.get().to_string());
        runs.set(runs.get() + 1);
    });
    count
});

Compare the actual browser panels

Load the supplied page after building both WebAssembly modules. The initial captures show zero in each panel and one text Effect run. Each signal write queues work, and the host flushes both runtimes after the current event handler.

Three writes in one event show three, with the text run count increasing only to two. Reset shows zero again and increases that count to three. Both the bound attribute and text agree, and the original Text node remains the same object.

These are actual browser captures with edited holds, not a timing benchmark.

Build the dependency kernel in MoonBit

The MoonBit runtime stores signals, callbacks and a queue of pending observers. A tracked read adds the current observer to the signal's subscribers. A write queues each subscriber once, so repeated writes before a flush do not duplicate that pending callback.

Before rerunning the callback, we remove its old subscriptions and collect the reads it makes this time. A branch-switch assertion checks that a signal no longer read by the Effect stops triggering it. Imagine a callback choosing between left and right values. After it switches to right, changing left should leave its run count unchanged; checking only the displayed number would miss an unnecessary rerun.

The same kernel supports the Signal episode and this browser example, rather than hiding state updates in JavaScript.

Excerpt from the executable source; the complete program is in the download.

fn[T] Runtime::run_effect(self : Runtime[T], id : Int) -> Unit {
  for slot in self.slots {
    slot.subscribers.retain(other => other != id)
  }
  let previous = self.observer
  self.observer = Some(id)
  self.effects[id]()
  self.observer = previous
}

Cross the ABI and preserve lifetime rules

MoonBit compiles this kernel to Wasm GC, while the Rust module uses wasm-bindgen. Their memory representations differ, so this example passes only integer values through the host boundary. Before moving a larger data structure across that boundary, verify one exported integer function and one imported host call. A language supporting WebAssembly does not automatically give every backend the same browser interface.

JavaScript owns the DOM nodes and exposes a text-setting operation to MoonBit. Writing the same integer still notifies in this tested Signal path; another reset therefore runs the Effect again. Disposing the owner clears reactive storage, and both versions reject a later write.

The comparison button checks these boundaries as well as the visible count.

Excerpt from the executable source; the complete program is in the download.

fn set_text(value : Int) -> Unit = "dom" "set_text"

///|
let runtime : @reactive.Runtime[Int] = @reactive.Runtime::new(value => value)

///|
let count : @reactive.Signal[Int] = runtime.signal(0)

Follow one additional dependency

Try adding a second Signal and predict which Effect should run when only that value changes. Trace the pinned runtime before changing the MoonBit queue, then keep an assertion for the expected count. The download includes a Node ABI check and a separate browser comparison; neither is a speed test.

This one-owner counter omits full component trees, server rendering, hydration and complete lifecycle management. Reinhardt Press links the real source so that a small reproduction can lead to a precise framework question or contribution.

Take the mechanism to your language

The portable parts are value storage, subscriber lists and a deduplicated queue; your language can represent them with its own records and collections.

For the browser, choose a WebAssembly target and define a host interface for DOM writes. Keep integer IDs at that boundary and check that three writes update the same node once.

Source map and limits

PORTING.md maps the pinned framework functions to the executable MoonBit implementation and names the omitted features. The reproduction uses different runtime representations and is a bounded investigation, not a complete port. Node 22 emits an experimental warning for its SQLite API.

Reinhardt contribution guide.