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.resourcesdef resources: ConnectedResourcesResources owned by the current connected LiveView lifecycle.
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 ConnectedResourcestrait ConnectedResourcesAcquisition and finalization owned by one connected LiveView lifecycle.
capability is exposed through the connected mount branch. Call
acquireReleasedef acquireRelease[A](acquire: zio.package.Task[A])(release: A => zio.package.UIO[Unit]): zio.package.Task[A]Acquires a resource and registers its finalizer with the current connected lifecycle.
there and provide a non-failing finalizer:
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:
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)View source (documentation/site/src/scalive/docs/examples/ConnectedResourceExample.scala:69-81)
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]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. Derive keys from stable instance identity when multiple copies
of an example or component can coexist:
private val ReportTask = 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] and map
its result into the owning LiveView's message type:
ctx.async
.start(ReportTask)(generateReport)(Msg.ReportCompleted(_))
.as(model.copy(report = model.report.loading()))The mapper receives a LiveAsyncResult[A]enum 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]enum 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.
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.canceldef cancel[A](key: AsyncKey[A], reason: Option[String] = ...): zio.package.Task[Unit] 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:
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 SubscriptionKeyopaque type SubscriptionKey = StringA nominal runtime key for one managed LiveView or component subscription. identifies one
registered stream in its root LiveView or exact component owner. The stream must
emit that owner's Msg type and cannot fail:
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(_))startdef start(key: SubscriptionKey, delivery: SubscriptionDelivery)(stream: zio.stream.ZStream[Any, Nothing, Msg]): zio.package.Task[Unit] 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:
ctx.subscriptions
.start(ClockSubscription, SubscriptionDelivery.Lossless)(ticks(1.second))replacedef replace(key: SubscriptionKey, delivery: SubscriptionDelivery)(stream: zio.stream.ZStream[Any, Nothing, Msg]): zio.package.Task[Unit] 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:
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.
canceldef cancel(key: SubscriptionKey): zio.package.Task[Unit] 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:
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.subscriptionsdef subscriptions: Subscriptions[Msg]
during connected mount and update, and through
ctx.subscriptionsdef subscriptions: Subscriptions[Msg]
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:
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(_))View source (documentation/site/src/scalive/docs/examples/SubscriptionClockExample.scala:11-92)
The component implementation shows two instances safely reusing one local key, mount-started streams, local replacement and cancellation, and parent-controlled visibility:
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")View source (documentation/site/src/scalive/docs/examples/ComponentSubscriptionsExample.scala:9-185)
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-150)
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:
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, ResetView source (documentation/site/src/scalive/docs/examples/ConnectedResourceExample.scala:10-65)
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.
Related Tasks
Inject the service that starts the work with Services and dependency injection.
Apply emitted collection changes with Streams and collection updates.
Choose a test boundary for connected behavior with Testing LiveViews.