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
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.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.
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
"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.UIAnalyzer walks the TypedExpr AST of main before generating any JavaScript. It sees:
state(0)— allocates state, assigns it an internal id like"count"read(c)— records that"count"is read during rendertext(int_to_String(current))insideh("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" }] };
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.
JsGen compiles IO operations into JavaScript thunks that return { value, trace } objects:
| Bosatsu | Generated 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) }; }; };
_ui_write_io call inside is only reached when the IO interpreter runs the chain.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.
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; };
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.
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) } };
_requiresRerender returns false (no conditional branches, no composite properties), so we take the _queuePathUpdate path — never the full render path.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.
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; } }); }
el.textContent = "1" assignment. The binding map told the runtime exactly which element and property to touch.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:
- User clicks the + button.
- Browser fires a native
clickevent. - The event listener calls
_ui_run_io(increment([])). - The
incrementthunk runs: readscountstate, computesvalue + 1, calls_ui_write_io(state, newValue)(). _ui_writesetsstate.value = newValue, then calls_queuePathUpdate("count").- On the next microtask,
_flushPendingUpdatescalls_updateBindings("count"). _updateBindingslooks up_bindings["count"], finds{ elementId: "count-display", property: "textContent" }.- 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:
- Conditional discriminant: the state is the value being matched in a
matchexpression. Changing it can swap entire DOM branches, so the binding map cannot handle it — a rebuild is needed. - 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
| Layer | What happens | Where 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 |