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)))
    }
  }
}