Reactive boundaries
Reactive boundaries are first-class AST nodes. The mount engine wires each once; updates patch only that boundary. Children are built right-to-left so a slot can find its insertion point from already-populated later siblings.
when
when(cond)(body) mounts/unmounts as a Squawk[Boolean] flips. The body is a thunk; rebuilt
fresh on each true-transition so per-render local state stays correct.
{
for open <- sq(true)
yield E.div(
E.button(Events.onClick(_ => open.update(!_)), "toggle"),
when(open)(E.p("visible")),
)
}visible
forEach
Keyed-list reconciliation reuses DOM nodes across reorders (focus/caret preserved). The key function is identity; the render fn builds from a snapshot value.
{
for items <- sq(Seq("a", "b", "c"))
yield E.ul(
forEach(items)(identity)(s => E.li(s))
)
}- a
- b
- c
forEachSignal
Like forEach, but each row holds a persistent Squawk[A] fed new values; rows never rebuild;
only boundaries bound to changed fields repaint. Prefer this when row UIs are heavy or hold
local state. Eq[A] gates the per-row dedup (derives Eq on the row type).
case class Row(id: String, n: Int) derives Eq
{
for items <- sq(Seq(Row("1", 1), Row("2", 2)))
yield E.ul(
forEachSignal(items)(_.id) { (id, _, signal) =>
E.li(id, ": ", signal.map(_.n.toString))
}
)
}- 1: 1
- 2: 2
scoped
scoped { … } opens a zio.Scope tied to a subtree's lifetime. Finalizers (conduit
subscriptions, rAF cancels, widget teardown) run when the subtree unmounts.
{
for n <- sq(0)
yield scoped {
ZIO.succeed(E.span(n.map(_.toString)))
}
}