← Back to Yichus

How a Yichus UI Effect Becomes a DOM Update

This page traces a single write effect from Bosatsu source through compilation, code generation, and the browser runtime until a DOM element changes on screen. It uses the counter demo as a running example.

Live Counter

Click a button and watch the pipeline light up

0
Clicks: 0
Re-renders: 0
1 Bosatsu Source — Pure Description Only compile time

The user writes a render function and event handlers in Bosatsu. Nothing here mutates anything — write returns IO[Unit], an inert value that describes a mutation but does not perform one.

package Demo/Counter

from Bosatsu/Predef import add, sub, Int, Unit, int_to_String
from Yichus/IO import IO, pure, flatMap
from Yichus/UI import VNode, State, state, read, write, h, text, on_click

count: IO[State[Int]] = state(0)

def increment(_: Unit) -> IO[Unit]: (
  c <- count.flatMap()
  current <- c.read().flatMap()
  c.write(current.add(1))
)

main: IO[VNode] =
  flatMap(count, c ->
    flatMap(read(c), current ->
      pure(h("div", [("class", "card")], [
        h("span", [("id", "count-display")], [
          text(int_to_String(current))
        ]),
        h("button", [on_click(increment)], [text("+")])
      ]))))
write has signature State[a] → a → IO[Unit]. Calling it constructs an IO value — a recipe for a future mutation. Nothing happens yet.
Compare to React ›

In React, setState(count + 1) immediately schedules a re-render of the entire component tree. Yichus defers everything behind the IO boundary — no effect runs until the runtime interprets the IO chain.

2 JVM Externals — IO Is an ADT, Not an Action compile time

On the JVM (Scala) side, write constructs a deferred FlatMap node. This is used during evaluation and testing, and is what proves that the IO boundary is real.

// From UI.scala — JVM external for write
private def deferredUiMutation(effect: => Unit): Value =
  ExternalValue(
    YichusIO.FlatMap(YichusIO.Pure(UnitValue), (_: Value) => {
      effect
      YichusIO.Pure(UnitValue)
    })
  )

The mutation closure is captured but not called until runWithProvenance interprets the FlatMap chain. A test in UITest.scala proves this:

val writeIo = call2("write", stateVal, Str("after"))
assertEquals(state.value, Str("before"))   // still "before"

val result = runIO(writeIo)                // interpret the IO
assertEquals(state.value, Str("after"))    // NOW it changed
The test "write mutation is deferred until IO is run" proves no mutation occurs before the IO runtime executes the chain. This is the guarantee that Yichus effects are values, not actions.
3 Compile-Time Static Analysis — Building the Binding Map compile time

UIAnalyzer walks the TypedExpr AST of main before generating any JavaScript. It sees:

  1. state(0) — allocates state, assigns it an internal id like "count"
  2. read(c) — records that "count" is read during render
  3. text(int_to_String(current)) inside h("span", ...) — the state read flows into a text node inside a <span>

From this it produces a DOMBinding:

// Scala compile-time structure
DOMBinding(
  elementId  = "count-display",   // auto-generated or explicit id
  property   = TextContent,        // this state drives textContent
  statePath  = List("count"),     // which state variable
  when       = None,               // always active (not conditional)
  transform  = Some("_int_to_String"),
  sourceExpr = ...                 // reference to the TypedExpr node
)

This binding is serialized into JavaScript and embedded in the generated HTML as a literal object:

const _bindings = {
  "count": [{
    elementId: "count-display",
    property:  "textContent",
    when:      null,
    transform: "_int_to_String"
  }]
};
Built at compile time by UIAnalyzer. No runtime discovery. This map is a constant — it never changes.
Compare to React ›

React has no binding map. Every setState triggers: render() → build virtual DOM → diff against previous vDOM → compute patches → apply to real DOM. Yichus resolves all of this at compile time.

4 JS Code Generation — What JsGen Emits compile time

JsGen compiles IO operations into JavaScript thunks that return { value, trace } objects:

BosatsuGenerated JS
pure(x)() => ({ value: x, trace: [] })
flatMap(io, f)Thunk chain: run io, pass result to f, concat traces
read(c)() => ({ value: c.value, trace: [] })
write(c, v)_ui_write_io(c, v) — returns a thunk

State allocation. state(0) compiles to a memoized thunk that creates or reuses a state object:

var count = (() => {
  let _state_ref = null;
  return () => {
    _state_ref = _state_ref || _ui_create_state(0);
    return { value: _state_ref, trace: [] };
  };
})();

_ui_create_state produces { id: "count", value: 0 }.

Event handler registration. on_click(increment) compiles to a prop tuple that stores the handler in a global table:

["data-onclick", _ui_register_handler("click", increment)]

_ui_register_handler stores the handler function under an id like "h_0" and returns that id. The VNode ends up with data-onclick="h_0" in its props.

The compiled increment handler. The entire function becomes a chain of IO thunks:

var increment = (_) => {
  // Returns an IO (a thunk). Nothing runs yet.
  return () => {
    const _a = count();                   // IO[State[Int]] → state obj
    const _b = (() => {
      const _a2 = (() => ({               // read(c) → IO thunk
        value: _a.value.value,            // reads stateObj.value
        trace: []
      }))();
      const _b2 = _ui_write_io(           // write(c, current + 1)
        _a.value,                          // the state object
        _a2.value + 1                      // new value
      )();                                 // ← invoked: effect runs
      return { value: _b2.value,
        trace: _a2.trace.concat(_b2.trace) };
    })();
    return { value: _b.value,
      trace: _a.trace.concat(_b.trace) };
  };
};
The outermost arrow returns a thunk. Nothing happens until something calls that thunk. The _ui_write_io call inside is only reached when the IO interpreter runs the chain.
Compare to React ›

React event handlers are plain functions that call setState directly. There's no thunk layer — the state mutation is immediate (though batched). Yichus generates a deferred chain that preserves IO semantics and trace metadata.

5 Click Dispatch — Where IO Is Finally Executed runtime

When the VNode tree is rendered to real DOM, props like data-onclick="h_0" get wired to addEventListener:

if (keyStr.startsWith('data-on')) {
  const handler = _ui_handlers[valStr];        // look up "h_0"
  el.addEventListener(handler.type, (e) => {
    _ui_run_io(handler.handler([]));           // call increment([])
  });
}

_ui_run_io is the IO interpreter — it calls the thunk:

var _ui_run_io = (io) => {
  if (typeof io === 'function') return io();
  return io;
};
Compare to React ›

React uses a synthetic event system that wraps native events, pools event objects, and schedules work through a fiber reconciler with priority lanes. Yichus wires native events directly and calls a thunk.

6 State Write — Bridging IO and DOM runtime

The base _ui_write_io from JsGen returns a thunk that calls _ui_write:

var _ui_write_io = (state, value) => () => {
  _ui_write(state, value);
  return { value: [], trace: [] };
};

UIGen overrides _ui_write to route through the binding map:

_ui_write = function(state, value) {
  state.value = value;

  const needsRerender = _requiresRerender(state.id);
  const hasBindings = !!(_bindings[state.id]
    && _bindings[state.id].length > 0);

  if (!needsRerender && hasBindings) {
    _queuePathUpdate(state.id);        // direct DOM patch
  }
  if (needsRerender) {
    _requestRender();                  // full rebuild (rare)
  }
};
For the counter, _requiresRerender returns false (no conditional branches, no composite properties), so we take the _queuePathUpdate path — never the full render path.
Compare to React ›

React's setState enqueues an update on the fiber, marks it as dirty, and schedules a reconciliation pass that re-executes the component function and diffs the output. Yichus sets the value and queues a single binding lookup — no tree walk.

7 DOM Update — The Actual Mutation runtime

On the next microtask, _flushPendingUpdates calls _updateBindings for each queued state id:

function _updateBindings(stateId) {
  const bindings = _bindings[stateId];

  bindings.forEach(binding => {
    const el = document.getElementById(binding.elementId)
      || document.querySelector(
        '[data-bosatsu-id="' + binding.elementId + '"]'
      );
    if (!el) return;

    const value = _applyBindingTransform(
      binding, read(stateId)
    );

    switch (binding.property) {
      case "textContent":
        el.textContent = _toJsTextValue(value);
        break;
      case "className":
        el.className = _toJsTextValue(value);
        break;
      case "value":
        el.value = _toJsTextValue(value);
        break;
      default:
        if (binding.property.startsWith("style.")) {
          el.style[prop] = _toJsTextValue(value);
        }
        break;
    }
  });
}
No virtual DOM diff. No render function call. One el.textContent = "1" assignment. The binding map told the runtime exactly which element and property to touch.
Compare to React ›

React would: call your component function → rebuild the entire JSX tree → diff old vs new vDOM nodes → walk the tree to find the one text node that changed → finally set textContent. Yichus did this step only.

The Full Click Sequence

Here is every step that happens when you click the + button, end to end:

  1. User clicks the + button.
  2. Browser fires a native click event.
  3. The event listener calls _ui_run_io(increment([])).
  4. The increment thunk runs: reads count state, computes value + 1, calls _ui_write_io(state, newValue)().
  5. _ui_write sets state.value = newValue, then calls _queuePathUpdate("count").
  6. On the next microtask, _flushPendingUpdates calls _updateBindings("count").
  7. _updateBindings looks up _bindings["count"], finds { elementId: "count-display", property: "textContent" }.
  8. Sets el.textContent = "1". Done.

When Does a Full Re-render Happen?

_requestRender() (which calls _renderApp() and rebuilds the VNode tree) only fires when _requiresRerender(stateId) returns true. This happens in two cases:

  1. Conditional discriminant: the state is the value being matched in a match expression. Changing it can swap entire DOM branches, so the binding map cannot handle it — a rebuild is needed.
  2. Composite property roots: two different state variables both feed into the same DOM property on the same element (detected at compile time by compositePropertyRerenderRoots).

For scalar state that feeds into a single DOM property (like the counter text), the render function is never re-called after initialization.

Summary

LayerWhat happensWhere in code
Bosatsu source write(c, v) returns IO[Unit] — a value, not an action counter.bosatsu
JVM runtime deferredUiMutation wraps mutation in FlatMap ADT node UI.scala
Static analysis UIAnalyzer extracts DOMBinding(elementId, textContent, statePath) UIAnalyzer.scala
JS codegen write_ui_write_io(state, value) (returns a thunk) JsGen.scala
JS runtime _ui_write sets value, queues _updateBindings via microtask UIGen.scala
DOM update _updateBindings does el.textContent = "1" — no diff, no rerender UIGen.scala