Conduit

For application state, ascent ships an optional bridge to conduit. Views never see conduit directly: they receive a Ctx[M] and speak two verbs; ctx.squawk(_.path) and ctx(action). Put the model and handlers in one file; view files import only ascent.*.

case class Model(draft: String, count: Int) derives Optics

enum Action extends conduit.Action:
  case SetDraft(text: String)
  case Inc

val M = Optics[Model]
val handler: ActionHandler[Model, Model, Nothing] =
  handle[Model, Model, Nothing](M):
    case Action.SetDraft(t) => focus(_.draft)(updated(t))
    case Action.Inc         => focus(_.count)(update(_ + 1))

Ctx verbs

ctx.squawk(_.draft) is a reactive slice. ctx(Action.SetDraft(…)) dispatches. Nested paths (ctx.squawk(_.a.b.c)), whole-model (ctx.model), and one-shot reads (ctx.read / ctx.current) are available.

for
  c <- Conduit(Model("", 0))(handler)
  ctx = c.ctx
  _   <- ctx(Action.SetDraft("hi"))
  _   <- c.run()
  cur <- ctx.read(_.draft)
yield cur
hi

View shape

for
  c <- Conduit(Model("", 0))(handler)
  ctx = c.ctx
  draft <- ctx.squawk(_.draft)
yield E.input(
  A.value(draft),
  Events.onInput(e => ctx(Action.SetDraft(e.targetValue.getOrElse("")))),
)

Element-scoped subscriptions

ctx.squawkKey(_.map, id) (and squawkAt / squawkAtVector) subscribe to one collection entry. Pair with scoped inside forEach so the listener unsubscribes when the row leaves; a churning list never accumulates dead listeners.

E.pre("""forEach(visible)(_.id) { t =>
  scoped {
    ctx.squawkKey(_.todos, t.id).map { item =>
      TodoItem.render(ctx)(t.id, item.map(_.getOrElse(t)))
    }
  }
}""")
forEach(visible)(_.id) { t =>
  scoped {
    ctx.squawkKey(_.todos, t.id).map { item =>
      TodoItem.render(ctx)(t.id, item.map(_.getOrElse(t)))
    }
  }
}