Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

Asynchronous work, subscriptions, and connected resources

Before You Start

Start with a connected LiveView whose messages already produce explicit model transitions and rendered UI states.

Choose The Resource By Shape

Scalive gives connected LiveViews and components focused lifecycle-owned resource shapes. Use ctx.async for one finite Task[A]: a database query, service call, or report that succeeds, fails, or is cancelled. Use ctx.subscriptions for a ZStream[Any, Nothing, Msg] that can emit many messages over time: a clock, notification feed, or application event stream. Use connected.resources during root LiveView mount for a resource that needs deterministic cleanup but does not itself deliver messages to the LiveView. ConnectedResources remains a root-mount capability; component subscriptions do not add a component resource registry.

A Task[A] is finite work that may fail with a Throwable; a fiber is its running, interruptible execution. A ZStream[R, E, A] emits zero or more A values over time. Scalive uses interruption to cancel work when its owner or a replacement disappears.

Use an injected service for durable or shared state. The APIs on this page own work only for one connected LiveView lifecycle.

These APIs attach resources to one connected LiveView lifecycle. Scalive releases them when that lifecycle closes. Managed async tasks and subscriptions also prevent replaced work from delivering stale completions. Prefer these APIs to fork inside a message handler: a manually forked fiber has no automatic owner, result delivery, or cleanup contract with the LiveView runtime.

Acquire Other Connected Resources

The ConnectedResources capability is exposed through the connected mount branch. Call acquireRelease there and provide a non-failing finalizer:

scala
trait SessionMonitor:
  def stop: UIO[Unit]

def startSessionMonitor: Task[SessionMonitor]

def mount(ctx: MountContext): Task[Model] =
  ctx.connection match
    case Connection.Disconnected =>
      loadDisconnectedModel

    case Connection.Connected(connected) =>
      connected.resources
        .acquireRelease(startSessionMonitor)(_.stop)
        .flatMap(monitor => loadConnectedModel(monitor))

The capability is intentionally limited to connected mount, either directly or through the route/session initializer below. Do not retain it in the model, a service, or a callback for later acquisition; use a keyed managed API or an explicitly scoped service when ownership changes after mount.

The acquisition Task[A] may fail normally. Once it succeeds, Scalive registers the finalizer before returning the value. That finalizer runs exactly once when the lifecycle closes, including when the remainder of connected mount or the initial render fails. An acquisition failure does not run the finalizer, and an attempt made after lifecycle closure fails before acquisition starts.

The finalizer is a UIO[Unit]: it has no expected typed failure. If an external cleanup API returns Task[Unit], make the policy explicit by recovering and logging, retrying within a bound, or deliberately converting the failure to a defect. Do not leave that decision implicit at shutdown.

ZIO runs acquisition and finalization uninterruptibly so interruption cannot strand a partially registered resource. Keep both effects short and bounded; a blocked acquisition or finalizer also delays lifecycle shutdown.

The owner is the connected LiveView lifecycle, not an application session ID. Two tabs using the same login session acquire independent resources, and closing one tab does not release the other tab's resource. Root and nested LiveViews on one WebSocket also have independent lifecycles; closing the socket releases all of them. Put genuinely session-shared resources in an injected service with explicit reference counting or leases.

Use this capability for registrations, observers, external subscriptions, and handles whose release is the only lifecycle interaction. Continue to use ctx.subscriptions when a timer or feed emits Msg values, or when work must be replaced or cancelled while the LiveView remains mounted.

Cleanup starts after the server observes lifecycle termination. External presence and locks still need leases or expiry for node failure and undetected network partitions.

Initialize At Route Or Session Boundaries

Use withConnectedResources on a route or named live-session builder when an acquired resource is only a lifecycle side effect, such as a registration or lease. Keep direct connected-mount acquisition when its result is needed to construct the page's model, as in the monitor example above.

The modifier accepts (Ctx, ConnectedResources) => Task[Result] with Result <: Unit. Finish an acquisition with .unit when the handle is needed only by its finalizer. The callback does not enrich typed context or populate a model, and it does not add a new environment requirement to the builder: capture services while assembling the application or obtain them from typed context.

This application attaches a registration to a page without changing the page:

Source
scala
object ConnectedResourceRouteExample:
  def application[Msg, Model](
    registrations: LifecycleRegistrations,
    page: LiveView[Msg, Model]
  ): LiveApplication[Any] =
    val registeredRoute = (live / "registered")
      .withConnectedResources { (_, resources) =>
        resources
          .acquireRelease(registrations.register("registered-page"))(registrations.unregister)
          .unit
      }

    Live.router(registeredRoute -> page)

Ctx is the context available where the modifier is installed. Plain route and session builders supply Any; install after the aspects that produce the typed context you need. Later aspects or admission preserve the earlier callback's context through projection, rather than passing it the final accumulated tuple. A later .context(factory) call does not retroactively type a plain route's initializer.

Initializers are lazy: neither constructing the builder nor disconnected rendering runs them. After all session and route admission/context checks succeed, Scalive runs session initializers first, then route initializers, in declaration order within each level, before the page's connected mount. Repeated modifiers append work; they do not replace earlier callbacks. This guarantee is about mount, not construction: page factories and render compilation can precede the initializers. Do not rely on registration side effects in a factory or while building the render graph.

Execution is sequential and fail-fast. A Task failure is a mount failure, not an authentication rejection or a LiveMountFailure result. Use mount aspects and admission for controlled authorization outcomes. All initializers and direct connected-mount acquisitions use the same existing ConnectedResources scope. If an initializer or the remaining mount/initial render fails, lifecycle closure rolls back successful acquisitions through their registered finalizers; later initializers do not run after a failure. Keep acquisition and finalization short and bounded, just as for direct mount acquisition.

The owner is one connected root lifecycle, not the physical socket, the named live-session group, or a logical application session. A fresh reconnect or route navigation runs the applicable initializers again for the new root. Patches and ordinary messages do not rerun them, and nested LiveViews do not inherit these route/session initializers. Nested views can still acquire their own resources directly during connected mount. Shared cross-tab/session ownership requires a separate service with explicit leases or reference counting.

Run Finite Work With A Typed Key

An AsyncKey[A] names one task and fixes its result type. Derive keys from stable instance identity when multiple copies of an example or component can coexist:

scala
private val ReportTask = AsyncKey[Report](s"async-report-$instanceId")

Start work through ctx.async.start and map its result into the owning LiveView's message type:

scala
ctx.async
  .start(ReportTask)(generateReport)(Msg.ReportCompleted(_))
  .as(model.copy(report = model.report.loading()))

The mapper receives a LiveAsyncResult[A]: Succeeded(value), Failed(cause), or Cancelled(reason). This is one task's completion, not persistent UI state. Handle it as an ordinary typed message so the model remains the single input to rendering.

Starting the same key again interrupts and replaces its previous task. The obsolete task does not emit Cancelled, and its stale completion is not delivered. This makes replacement suitable for refreshes and changing queries: start the newest request under the same key instead of assigning request IDs and filtering old results yourself.

Model Finite Work With AsyncValue

AsyncValue[A] is an optional application model for rendering work across messages. It distinguishes empty, loading, successful, failed, and explicitly cancelled states. Loading, failure, and cancellation can retain the last successful value, which lets a refresh show existing data instead of replacing the entire panel with a spinner.

scala
final case class Model(report: AsyncValue[Report] = AsyncValue.empty)

case Msg.ReportCompleted(result) =>
  ZIO.succeed(model.copy(report = model.report.updated(result)))

Call loading(reset = true) when old data would be misleading. The default retains it. Render every state deliberately; a failed task is domain-visible state, not a reason for the LiveView process to crash.

Do not expose arbitrary exception messages to visitors. Convert expected failures into stable, actionable copy and log diagnostic context separately. The report example displays a fixed failure explanation while the documentation site's diagnostic state viewer records only the state label.

Cancel Explicitly When It Is User-Visible

ctx.async.cancel interrupts active work. If the key exists, Scalive invokes the original mapper with LiveAsyncResult.Cancelled, including the optional application reason. Use this path when cancellation itself belongs in the UI:

scala
ctx.async.cancel(ReportTask, Some("Cancelled by the user")).as(model)

Cancelling an absent key is a no-op. Replacement, component removal, and socket shutdown also interrupt work, but intentionally do not deliver cancellation messages: their owner is obsolete or disappearing.

Reset needs an explicit policy. The example cancels active work and immediately returns to AsyncValue.Empty; it then ignores the cancellation completion only when the model is already empty. A reset that should display “cancelled” can instead reuse the normal cancel path.

Own Long-Lived Streams With Subscriptions

A SubscriptionKey identifies one registered stream in its root LiveView or exact component owner. The stream must emit that owner's Msg type and cannot fail:

scala
private val ClockSubscription =
  SubscriptionKey(s"subscription-clock-$instanceId")

private def ticks(every: Duration): ZStream[Any, Nothing, Msg] =
  ZStream.tick(every).mapZIO(_ => Clock.instant).map(Msg.Tick(_))

start rejects a duplicate registered key, including a component registration whose worker is suspended by dormancy. Use it when starting twice indicates a state-machine mistake. The clock guards start with its model so the button and server transition agree:

scala
ctx.subscriptions
  .start(ClockSubscription, SubscriptionDelivery.Lossless)(ticks(1.second))

replace starts or swaps the stream under a key. Replacing the registration interrupts the old stream and resubscribes the runtime's current set. Use replacement when changing polling frequency, topic, props, or another stream parameter is valid application behavior:

scala
ctx.subscriptions
  .replace(ClockSubscription, SubscriptionDelivery.Lossless)(ticks(250.millis))

The required SubscriptionDelivery argument controls backpressure at the LiveView mailbox. Lossless delivers every emitted message in order; use it when every transition matters. Latest coalesces pending delivery so newer messages supersede older ones; use it for high-frequency state snapshots where only the newest value matters. The compiled clock example below deliberately uses Lossless for both start and replacement so every tick is counted.

cancel removes the registration and succeeds when it is already absent. Update the model in the same handler so the rendered controls describe the registered state:

scala
case Msg.Cancel =>
  ctx.subscriptions
    .cancel(ClockSubscription)
    .as(model.copy(mode = Mode.Stopped))

Root subscription messages pass through the root's typed info lifecycle hooks before handleMessage. Registrations exist only for the connected lifecycle and do not run during disconnected rendering. Mount therefore starts required streams again for each new connected lifecycle.

Construct each stream so resources are acquired anew every time it runs. Put handles inside ZStream.scoped or ZStream.unwrapScoped; do not acquire a handle first and capture it in a stream value. Replacement and component revival rerun the stored stream, so a captured handle may already have been released.

Own A Stream In A Component

Components expose the same Subscriptions[Msg] capability through connected.subscriptions during connected mount and update, and through ctx.subscriptions while handling a component message. A registered stream emits the component's Msg, not the parent LiveView's message type. Delivery follows the component's typed event hooks and handleMessage; component async completions instead use the component's async hooks. Root info hooks do not handle either kind of component message.

The subscription namespace is the exact runtime component instance. Two instances can deliberately reuse the same SubscriptionKey without collision, and root registrations remain independent. A disconnected component mount or update cannot acquire a subscription because the capability exists only in its Connection.Connected branch.

When a rendered component becomes dormant while the runtime waits for browser removal confirmation, Scalive stops its subscription worker but retains the registration with the component model. If that same instance returns first, the runtime runs the stored ZStream again with fresh internal tokens, without calling mount or update merely to restart it. Pending values from the old worker are discarded. This is interruption and resubscription, not a replay or lossless handoff guarantee.

Only a stream still actively registered at dormancy is eligible for this automatic restart. An explicitly cancelled stream, a normally completed stream, or a defective stream stays stopped. Once the browser confirms destruction, Scalive removes the registration; rendering the same class and logical ID later mounts a fresh component and may register a fresh stream.

Component async behavior is intentionally unchanged: dormancy interrupts an active component async task and returning does not restart it. Root async and subscription ownership is also unchanged by component dormancy.

Keep Ownership Local

Keys are runtime identities, not persistence keys. Keep them stable within one owner and unique among that owner's resources. Exact component instances and nested LiveViews have independent resource namespaces. Deriving root keys from instance identity still makes ownership visible in traces and prevents collisions when code moves into a shared owner.

Keep durable results in an application service or database when they must survive navigation or reconnect. AsyncValue, task registrations, and subscription registrations are lifecycle state. On page exit, Scalive releases the managed resource; on remount, initialize the model and registrations from their real source of truth.

Study The Complete Examples

The clock implementation shows start, replacement, cancellation, reset, and an instance-scoped subscription key:

Source
scala
final class SubscriptionClockExample(instanceId: String)
    extends LiveView[SubscriptionClockExample.Msg, SubscriptionClockExample.Model]:
  import SubscriptionClockExample.*

  private val ClockSubscription = subscriptionKey(instanceId)

  def mount(ctx: MountContext): Task[Model] =
    ZIO.succeed(Model())

  def handleMessage(model: Model, ctx: MessageContext) =
    case Msg.Start =>
      if model.mode == Mode.Stopped then
        ctx.subscriptions
          .start(ClockSubscription, SubscriptionDelivery.Lossless)(ticks(1.second))
          .as(model.copy(mode = Mode.EverySecond))
      else ZIO.succeed(model)
    case Msg.Replace =>
      ctx.subscriptions
        .replace(ClockSubscription, SubscriptionDelivery.Lossless)(ticks(250.millis))
        .as(model.copy(mode = Mode.FourTimesPerSecond))
    case Msg.Cancel =>
      ctx.subscriptions.cancel(ClockSubscription).as(model.copy(mode = Mode.Stopped))
    case Msg.Reset =>
      ctx.subscriptions.cancel(ClockSubscription).as(Model())
    case Msg.Tick(at) =>
      ZIO.succeed(model.copy(lastTick = Some(at), tickCount = model.tickCount + 1))

  def view(model: Signal[Model]): HtmlElement[Msg] =
    div(
      cls := "docs-managed-work",
      sectionTag(
        cls        := "docs-managed-work-state",
        aria.label := "Clock subscription state",
        p("Mode", strong(dataAttr("clock-mode") := "", model.map(_.mode.label))),
        p("Ticks received", strong(dataAttr("clock-count") := "", model.map(_.tickCount.toString))),
        p(
          "Latest tick",
          span(dataAttr("clock-tick") := "", model.map(_.lastTick.fold("Waiting")(_.toString)))
        )
      ),
      div(
        cls := "docs-managed-work-controls",
        button(
          typ      := "button",
          disabled := model.map(_.mode != Mode.Stopped),
          on.click(Msg.Start),
          "Start every second"
        ),
        button(typ := "button", on.click(Msg.Replace), "Replace with fast clock"),
        button(
          typ      := "button",
          disabled := model.map(_.mode == Mode.Stopped),
          on.click(Msg.Cancel),
          "Cancel clock"
        )
      )
    )
end SubscriptionClockExample

object SubscriptionClockExample:
  enum Mode(val label: String):
    case Stopped            extends Mode("Stopped")
    case EverySecond        extends Mode("Every second")
    case FourTimesPerSecond extends Mode("Four times per second")

  final case class Model(
    mode: Mode = Mode.Stopped,
    lastTick: Option[Instant] = None,
    tickCount: Int = 0)

  enum Msg:
    case Start
    case Replace
    case Cancel
    case Reset
    case Tick(at: Instant)

  private[docs] def subscriptionKey(instanceId: String): SubscriptionKey =
    SubscriptionKey(s"subscription-clock-$instanceId")

  private def ticks(every: Duration): ZStream[Any, Nothing, Msg] =
    ZStream.tick(every).mapZIO(_ => Clock.instant).map(Msg.Tick(_))

The component implementation shows two instances safely reusing one local key, mount-started streams, local replacement and cancellation, and parent-controlled visibility:

Source
scala
object SubscriptionTickerComponent
    extends LiveComponent[
      SubscriptionTickerComponent.Props,
      SubscriptionTickerComponent.Msg,
      SubscriptionTickerComponent.Model
    ]:
  final case class Props(id: String, title: String, resetEpoch: Int)

  enum Mode(val label: String):
    case Stopped            extends Mode("Stopped")
    case EverySecond        extends Mode("Every second")
    case FourTimesPerSecond extends Mode("Four times per second")

  final case class Model(
    mode: Mode = Mode.EverySecond,
    ticks: Int = 0,
    resetEpoch: Int = 0)

  enum Msg:
    case Start
    case Replace
    case Cancel
    case Tick

  // Both component instances deliberately reuse this key. The runtime namespace
  // is the exact component instance, so their registrations do not collide.
  private val LocalTicks = SubscriptionKey("component-local-ticks")

  def mount(props: Props, ctx: MountContext): Task[Model] =
    ctx.connection match
      case Connection.Disconnected         => ZIO.succeed(Model(resetEpoch = props.resetEpoch))
      case Connection.Connected(connected) =>
        connected.subscriptions
          .start(LocalTicks, SubscriptionDelivery.Lossless)(ticks(1.second))
          .as(Model(resetEpoch = props.resetEpoch))

  override def update(props: Props, model: Model, ctx: UpdateContext): Task[Model] =
    if props.resetEpoch == model.resetEpoch then ZIO.succeed(model)
    else
      ctx.connection match
        case Connection.Disconnected         => ZIO.succeed(Model(resetEpoch = props.resetEpoch))
        case Connection.Connected(connected) =>
          connected.subscriptions
            .replace(LocalTicks, SubscriptionDelivery.Lossless)(ticks(1.second))
            .as(Model(resetEpoch = props.resetEpoch))

  def handleMessage(props: Props, model: Model, ctx: MessageContext) =
    case Msg.Start =>
      if model.mode == Mode.Stopped then
        ctx.subscriptions
          .start(LocalTicks, SubscriptionDelivery.Lossless)(ticks(1.second))
          .as(model.copy(mode = Mode.EverySecond))
      else ZIO.succeed(model)
    case Msg.Replace =>
      ctx.subscriptions
        .replace(LocalTicks, SubscriptionDelivery.Lossless)(ticks(250.millis))
        .as(model.copy(mode = Mode.FourTimesPerSecond))
    case Msg.Cancel =>
      ctx.subscriptions.cancel(LocalTicks).as(model.copy(mode = Mode.Stopped))
    case Msg.Tick =>
      ZIO.succeed(model.copy(ticks = model.ticks + 1))

  def view(props: Signal[Props], model: Signal[Model], self: ComponentRef[Msg]) =
    articleTag(
      cls                                := "docs-vote-card",
      dataAttr("subscription-component") := props.map(_.id),
      headerTag(
        div(
          p(cls := "docs-vote-kicker", "Component-owned subscription"),
          h4(props.map(_.title))
        ),
        code(dataAttr("component-id") := props.map(_.id), props.map(_.id))
      ),
      div(
        cls := "docs-vote-count-row",
        div(
          cls := "docs-vote-metric",
          span("Ticks"),
          strong(dataAttr("component-ticks") := "", model.map(_.ticks.toString))
        ),
        p(dataAttr("component-mode") := "", model.map(_.mode.label))
      ),
      div(
        cls := "docs-vote-actions",
        button(
          typ      := "button",
          disabled := model.map(_.mode != Mode.Stopped),
          phx.target(self),
          on.click.to(self)(Msg.Start),
          "Start local ticks"
        ),
        button(
          typ := "button",
          phx.target(self),
          on.click.to(self)(Msg.Replace),
          "Replace local ticks"
        ),
        button(
          typ      := "button",
          disabled := model.map(_.mode == Mode.Stopped),
          phx.target(self),
          on.click.to(self)(Msg.Cancel),
          "Cancel local ticks"
        )
      )
    )

  private def ticks(every: Duration): ZStream[Any, Nothing, Msg] =
    ZStream.repeatZIO(ZIO.sleep(every).as(Msg.Tick))
end SubscriptionTickerComponent

final class ComponentSubscriptionsExample
    extends LiveView[ComponentSubscriptionsExample.Msg, ComponentSubscriptionsExample.Model]:
  import ComponentSubscriptionsExample.*

  def mount(ctx: MountContext): Task[Model] = ZIO.succeed(Model())

  def handleMessage(model: Model, ctx: MessageContext) =
    case Msg.ToggleFirst => ZIO.succeed(model.copy(firstVisible = !model.firstVisible))
    case Msg.Reset       =>
      ZIO.succeed(model.copy(firstVisible = true, resetEpoch = model.resetEpoch + 1))

  def view(model: Signal[Model]): HtmlElement[Msg] =
    div(
      cls := "docs-voting-components",
      sectionTag(
        cls        := "docs-vote-parent",
        aria.label := "Parent component visibility",
        div(
          p(cls := "docs-vote-kicker", "Parent-owned visibility"),
          p(
            dataAttr("first-visibility") := "",
            model.map(value =>
              if value.firstVisible then "First ticker is rendered."
              else "First ticker is removed."
            )
          )
        ),
        button(
          typ := "button",
          on.click(Msg.ToggleFirst),
          model.map(value =>
            if value.firstVisible then "Remove first ticker" else "Reinsert first ticker"
          )
        )
      ),
      div(
        cls := "docs-vote-grid",
        model
          .map(_.firstVisible).when(
            div(
              FirstTicker.render(
                model.map(value =>
                  SubscriptionTickerComponent
                    .Props("first-ticker", "First ticker", value.resetEpoch)
                )
              )
            )
          ),
        SecondTicker.render(
          model.map(value =>
            SubscriptionTickerComponent.Props("second-ticker", "Second ticker", value.resetEpoch)
          )
        )
      )
    )
end ComponentSubscriptionsExample

object ComponentSubscriptionsExample:
  final case class Model(firstVisible: Boolean = true, resetEpoch: Int = 0)

  enum Msg:
    case ToggleFirst
    case Reset

  private val FirstTicker  = component(SubscriptionTickerComponent, "first-ticker")
  private val SecondTicker = component(SubscriptionTickerComponent, "second-ticker")

The report implementation shows typed results, retained values, deterministic failure, stale-completion suppression, explicit cancellation, retry, and reset:

Source
scala
final class AsyncReportExample(instanceId: String)
    extends LiveView[AsyncReportExample.Msg, AsyncReportExample.Model]:
  import AsyncReportExample.*

  private val ReportTask = reportKey(instanceId)

  def mount(ctx: MountContext): Task[Model] =
    ZIO.succeed(Model())

  def handleMessage(model: Model, ctx: MessageContext) =
    case Msg.RunSuccess => start(model, ctx, successfulReport)
    case Msg.RunFailure => start(model, ctx, failingReport)
    case Msg.Replace    => start(model, ctx, replacementReport)
    case Msg.Retry      => start(model, ctx, retryReport)
    case Msg.Cancel     =>
      if model.report.isLoading then
        ctx.async.cancel(ReportTask, Some("Cancelled by the user")).as(model)
      else ZIO.succeed(model)
    case Msg.Reset =>
      ctx.async.cancel(ReportTask, Some("Example reset")).as(Model())
    case Msg.ReportCompleted(LiveAsyncResult.Cancelled(_)) if model.report == AsyncValue.Empty =>
      ZIO.succeed(model)
    case Msg.ReportCompleted(result) =>
      ZIO.succeed(model.copy(report = model.report.updated(result)))

  def view(model: Signal[Model]): HtmlElement[Msg] =
    val report = model.map(_.report)
    div(
      cls := "docs-managed-work",
      div(
        cls := "docs-managed-work-controls",
        button(typ := "button", on.click(Msg.RunSuccess), "Run successful report"),
        button(typ := "button", on.click(Msg.RunFailure), "Run failing report"),
        button(typ := "button", on.click(Msg.Replace), "Replace current work"),
        button(typ := "button", on.click(Msg.Retry), "Retry report"),
        button(
          typ      := "button",
          disabled := report.map(!_.isLoading),
          on.click(Msg.Cancel),
          "Cancel report"
        )
      ),
      report
        .map(_ == AsyncValue.Empty).choose(
          sectionTag(
            dataAttr("report-state") := "",
            aria.live                := "polite",
            "Empty"
          ),
          reportPanel(
            report.map(reportState),
            report.map(reportValue),
            report.map(reportStatus)
          )
        )
    )
  end view

  private def start(model: Model, ctx: MessageContext, task: Task[Report]) =
    ctx.async
      .start(ReportTask)(task)(Msg.ReportCompleted(_))
      .as(model.copy(report = model.report.loading()))

  private def reportPanel(
    state: Signal[String],
    report: Signal[Option[Report]],
    status: Signal[String]
  ): HtmlElement[Msg] =
    sectionTag(
      dataAttr("report-state") := "",
      aria.live                := "polite",
      h2(dataAttr("report-status") := "", state),
      p(status),
      report.option { value =>
        articleTag(
          h3(dataAttr("report-title") := "", value.map(_.title)),
          p(value.map(_.summary)),
          p(value.map(value => s"${value.rows} rows"))
        )
      }
    )

  private def reportState(value: AsyncValue[Report]): String = value match
    case AsyncValue.Empty           => "Empty"
    case AsyncValue.Loading(_)      => "Loading"
    case AsyncValue.Ok(_)           => "Succeeded"
    case AsyncValue.Failed(_, _)    => "Failed"
    case AsyncValue.Cancelled(_, _) => "Cancelled"

  private def reportValue(value: AsyncValue[Report]): Option[Report] = value match
    case AsyncValue.Empty                  => None
    case AsyncValue.Loading(previous)      => previous
    case AsyncValue.Ok(report)             => Some(report)
    case AsyncValue.Failed(previous, _)    => previous
    case AsyncValue.Cancelled(previous, _) => previous

  private def reportStatus(value: AsyncValue[Report]): String = value match
    case AsyncValue.Empty                => "Empty"
    case AsyncValue.Loading(_)           => "Generating report..."
    case AsyncValue.Ok(_)                => "Report completed."
    case AsyncValue.Failed(_, _)         => "The deterministic data source rejected the report."
    case AsyncValue.Cancelled(_, reason) =>
      reason.getOrElse("Report generation was cancelled.")
end AsyncReportExample

object AsyncReportExample:
  final case class Report(title: String, rows: Int, summary: String)
  final case class Model(report: AsyncValue[Report] = AsyncValue.empty)

  enum Msg:
    case RunSuccess
    case RunFailure
    case Replace
    case Retry
    case Cancel
    case Reset
    case ReportCompleted(result: LiveAsyncResult[Report])

  private[docs] def reportKey(instanceId: String): AsyncKey[Report] =
    AsyncKey[Report](s"async-report-$instanceId")

  private def successfulReport: Task[Report] =
    ZIO
      .sleep(2.seconds).as(
        Report("Quarterly activity", 128, "The deterministic success path completed normally.")
      )

  private def failingReport: Task[Report] =
    ZIO.sleep(1.second) *>
      ZIO.fail(new RuntimeException("The deterministic data source rejected the report."))

  private def replacementReport: Task[Report] =
    ZIO
      .sleep(600.millis).as(
        Report("Replacement report", 64, "The replacement suppressed the obsolete completion.")
      )

  private def retryReport: Task[Report] =
    ZIO
      .sleep(800.millis).as(
        Report("Retried report", 128, "The retry completed with deterministic data.")
      )
end AsyncReportExample

The connected registration example shows the acquired handle in its initial model, updates ordinary model state without reacquiring, and releases the exact handle when its LiveView closes:

Source
scala
final case class LifecycleRegistration(id: String)

trait LifecycleRegistrations:
  def register(owner: String): UIO[LifecycleRegistration]
  def unregister(registration: LifecycleRegistration): UIO[Unit]

final class ConnectedResourceExample(
  instanceId: String,
  registrations: LifecycleRegistrations)
    extends LiveView[ConnectedResourceExample.Msg, ConnectedResourceExample.Model]:
  import ConnectedResourceExample.*

  def mount(ctx: MountContext): Task[Model] = ctx.connection match
    case Connection.Disconnected         => ZIO.succeed(Model())
    case Connection.Connected(connected) =>
      connected.resources
        .acquireRelease(registrations.register(instanceId))(registrations.unregister)
        .map(registration => Model(Some(registration)))

  def handleMessage(model: Model, ctx: MessageContext) =
    case Msg.Check =>
      ZIO.succeed(model.copy(checks = model.checks + 1))
    case Msg.Reset =>
      ZIO.succeed(model.copy(checks = 0))

  def view(model: Signal[Model]): HtmlElement[Msg] =
    val status = model.map(_.registration.fold("Waiting for connected mount")(_ => "Acquired"))
    val handle = model.map(_.registration.fold("Not acquired")(_.id))

    div(
      cls := "docs-managed-work",
      sectionTag(
        cls        := "docs-managed-work-state",
        aria.label := "Connected lifecycle registration",
        p("Status", strong(dataAttr("connected-resource-status") := "", status)),
        p("Handle", code(dataAttr("connected-resource-handle") := "", handle)),
        p(
          "Model-only checks",
          strong(dataAttr("connected-resource-checks") := "", model.map(_.checks.toString))
        )
      ),
      div(
        cls := "docs-managed-work-controls",
        button(typ := "button", on.click(Msg.Check), "Update model"),
        button(typ := "button", on.click(Msg.Reset), "Reset checks")
      )
    )
end ConnectedResourceExample

object ConnectedResourceExample:
  final case class Model(
    registration: Option[LifecycleRegistration] = None,
    checks: Int = 0)

  enum Msg:
    case Check, Reset

Run the connected lifecycle registration, managed clock subscription, component-owned subscriptions, and managed async report alongside the documentation site's diagnostic views to correlate messages, model transitions, and final DOM changes.