Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

Asynchronous work

Before you start

Start with a connected LiveView whose messages already produce explicit model transitions and rendered states. Loading data and live updates introduces this page's API with a complete example.

Choose the resource by shape

Scalive gives each connected LiveView and component three kinds of lifecycle-owned work:

  • Asynchronous work, on this page, runs one finite Task[A]: a database query, service call, or report that succeeds, fails, or is cancelled.

  • Subscriptions run a ZStream[Any, Nothing, Msg] that can emit many messages over time: a clock, notification feed, or application event stream.

  • Connected resources hold something that needs cleanup when the page closes but never sends the LiveView a message.

Scalive releases each of them when the connected lifecycle closes, and prevents replaced work from delivering stale results. Prefer them to fork inside a message handler: a manually forked fiber has no owner, result delivery, or cleanup tied to the LiveView. Keep durable or shared state in an injected service instead.

Start work when the page connects

Most pages load slow data in mount, once the browser has connected. Match ctx.connection and start the work in the connected branch:

Source
scala
def mount(ctx: MountContext): Task[Model] =
  ctx.connection match
    case Connection.Disconnected =>
      ZIO.succeed(Model(reading = AsyncValue.loading))
    case Connection.Connected(connected) =>
      for
        _ <- connected.async.start(Reading)(thermostat.temperature)(Msg.Measured(_))
        _ <- connected.subscriptions.start(RefreshTimer, SubscriptionDelivery.Latest)(
               ZStream.fromSchedule(Schedule.spaced(RefreshEvery)).as(Msg.Refresh)
             )
      yield Model(reading = AsyncValue.loading)

connected.async exists only in the Connection.Connected branch, so the disconnected HTTP render cannot start background work. Return a model that renders a loading state from both branches. Every connected mount, including the one after a reconnect, starts the work again. The second statement starts a subscription.

After mount, message handlers reach the same capability through ctx.async.

Run finite work with a typed key

An AsyncKey[A] names one task and fixes its result type, as in AsyncKey[Double]("reading"). Derive keys from stable instance identity when multiple copies of an example or component can coexist; the report example below uses AsyncKey[Report](s"async-report-$instanceId").

Start work through ctx.async.start and map its result into the owning LiveView's message type. The report example starts every run through one helper:

Source
scala
private def start(model: Model, ctx: MessageContext, task: Task[Report]) =
  ctx.async
    .start(ReportTask)(task)(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. Keep it in the model:

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

Then apply each completion with updated:

Source
scala
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:

Source
scala
case Msg.Cancel =>
  if model.report.isLoading then
    ctx.async.cancel(ReportTask, Some("Cancelled by the user")).as(model)
  else ZIO.succeed(model)

Cancelling an absent key is a no-op. Replacement, component removal, and lifecycle 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.

Keep ownership local

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

Components start work the same way, through connected.async in their connected mount or update and ctx.async in their message handler. Completions pass through the component's async hooks before its handleMessage. When a component becomes dormant or is removed, Scalive interrupts its active task; a dormant component that returns does not restart it.

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

Study the complete example

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

Run the managed async report alongside the documentation site's diagnostic views to correlate messages, model transitions, and final DOM changes.