Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

Component-owned subscriptions

Both ticker cards use the same SubscriptionKey, but each registration belongs to its exact runtime component instance. Cancel or replace one ticker and the other continues independently.

The parent visibility control also exposes the component removal boundary. Leave the first ticker absent long enough for the browser to confirm its destruction, then reinsert it to get a fresh model and mount-started stream. Remove and reinsert it very quickly, before that confirmation reaches the server, and the retained model returns while its stored stream starts again with a fresh runtime token. Tick delivery is Lossless while a worker is running so the visible counter records every delivered tick, but interruption still provides no replay or lossless guarantee for values emitted while the worker was stopped.

The example's reset increments a parent-owned epoch. Each component update sees that changed prop, replaces its local stream, and clears its model. The ordinary initial update keeps the mount-started registration untouched, avoiding a duplicate start.

LiveView and LiveComponent
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")

Live result

ConnectedReconnectingRead-only

Parent-owned visibility

First ticker is rendered.

Component-owned subscription

First ticker

first-ticker
Ticks0

Every second

Component-owned subscription

Second ticker

second-ticker
Ticks0

Every second

Disconnected. Controls resume after reconnection.

LiveView observability

Interaction inspector

See how actions in the live result above travel through typed messages, server state, protocol frames, and DOM updates.

Reconnect to inspect live interactions

The live result and its inspector resume when the connection returns.

Related guidance: own work in an exact component instance.