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.connectiondef connection: Connection[Connected]Whether this stage runs for the disconnected HTTP render or a connected lifecycle. and start
the work in the connected branch:
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)View source (documentation/fixtures/learn/src/quickstart/ThermostatLiveView.scala:14-24)
connected.asyncdef async: Async[Msg]Starts finite background work whose result returns as a message. 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.asyncdef async: Async[Msg]Starts and cancels finite background tasks whose results return as messages..
Run finite work with a typed key
An AsyncKey[A]opaque type AsyncKey >: ([A] =>> Nothing) <: ([A] =>> Any) = ([A] =>> String)A nominal key that associates an asynchronous task name with its result type. 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.startdef start[A](key: AsyncKey[A])(task: zio.package.Task[A])(toMsg: LiveAsyncResult[A] => Msg): zio.package.Task[Unit]Runs task in the background and sends toMsg(result) to the owner when it completes. and map
its result into the owning LiveView's message type. The report example starts
every run through one helper:
private def start(model: Model, ctx: MessageContext, task: Task[Report]) =
ctx.async
.start(ReportTask)(task)(Msg.ReportCompleted(_))
.as(model.copy(report = model.report.loading()))View source (documentation/site/src/scalive/docs/examples/AsyncReportExample.scala:71-74)
The mapper receives a LiveAsyncResult[A]enum LiveAsyncResult[+A]How one task started with start ended. Carry it in a message to the owner.:
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]enum AsyncValue[+A]The state of background work, kept in the model to render loading, result, and failure. 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:
final case class Model(report: AsyncValue[Report] = AsyncValue.empty)View source (documentation/site/src/scalive/docs/examples/AsyncReportExample.scala:122-122)
Then apply each completion with updated:
case Msg.ReportCompleted(result) =>
ZIO.succeed(model.copy(report = model.report.updated(result)))View source (documentation/site/src/scalive/docs/examples/AsyncReportExample.scala:33-34)
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.canceldef cancel[A](key: AsyncKey[A], reason: Option[String] = ...): zio.package.Task[Unit]Interrupts the task running under key and sends its Cancelled message. 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:
case Msg.Cancel =>
if model.report.isLoading then
ctx.async.cancel(ReportTask, Some("Cancelled by the user")).as(model)
else ZIO.succeed(model)View source (documentation/site/src/scalive/docs/examples/AsyncReportExample.scala:23-26)
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.asyncdef async: Async[Msg]Starts finite background work owned by this component instance, whose result returns as a
message. in their
connected mount or update and
ctx.asyncdef async: Async[Msg]Starts and cancels finite background tasks owned by this component instance, whose results
return as messages. 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:
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 AsyncReportExampleView source (documentation/site/src/scalive/docs/examples/AsyncReportExample.scala:8-158)
Run the managed async report alongside the documentation site's diagnostic views to correlate messages, model transitions, and final DOM changes.
Related tasks
Emit many messages over time with Subscriptions.
Release a registration or handle when the page closes with Connected resources.
Inject the service that performs the work with Services and dependency injection.
Save a form in the background with Saving forms.
Test connected behavior with Testing LiveViews.