Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

Stateful components and communication

Before You Start

Start with a LiveView whose rendered model changes in response to a typed message. If the component will own a form, first make that form work with typed validation.

Choose A Stateful Component

Use a LiveComponent when one reusable piece of application UI needs its own model, messages, and lifecycle. Keep ordinary markup in functions when it does not need isolated state. Every stateful instance is identified by its component class and stable logical ID.

Use a nested LiveView instead when the child needs a separate socket rather than only isolated state.

scala
private val ScalaVote = component(VoteComponent, "scala-vote")
private val ZioVote   = component(VoteComponent, "zio-vote")

The ID is application identity within that component class. An ComponentRef is an opaque, type-safe semantic identity for the exact mounted component. Its representation is deliberately hidden: do not treat it as a numeric CID or persist it as domain state.

Separate Props, Messages, Model, And Output

The component APIs separate four independent roles:

  • Props are values supplied by the owner.

  • Msg values are inputs handled by the component.

  • Model is state isolated to one component instance.

  • Output values, when declared, report domain events to the immediate owner.

Only an output-producing component declares Output, through LiveComponent.WithOutput. An ordinary LiveComponent[Props, Msg, Model] has no output type parameter and uses the simpler render(props) placement API.

This compact path shows a local message becoming an output and then a parent message:

scala
object VoteComponent
    extends LiveComponent.WithOutput[
      VoteComponent.Props,
      VoteComponent.Msg,
      VoteComponent.Model,
      VoteComponent.Output
    ]:
  final case class Props(id: String)
  final case class Model(votes: Int)

  enum Msg:
    case Vote

  enum Output:
    case VoteChanged(id: String, votes: Int)

  def mount(props: Props, ctx: MountContext) = ZIO.succeed(Model(0))

  def handleMessage(props: Props, model: Model, ctx: MessageContext) =
    case Msg.Vote =>
      val updated = model.copy(votes = model.votes + 1)
      ctx.emit(Output.VoteChanged(props.id, updated.votes)).as(updated)

  def view(props: Signal[Props], model: Signal[Model], self: ComponentRef[Msg]) =
    button(phx.target(self), on.click.to(self)(Msg.Vote), model.map(_.votes.toString))

object VotingLiveView:
  enum Msg:
    case ComponentReported(id: String, votes: Int)

  private val ScalaVote = component(VoteComponent, "scala-vote")

  def renderVote(props: Signal[VoteComponent.Props]) =
    ScalaVote.render(
      props,
      output => output match
        case VoteComponent.Output.VoteChanged(id, votes) =>
          Msg.ComponentReported(id, votes)
    )

Keep Local Events Local

Bindings rendered by a component accept its Msg values. As above, pair phx.target(self) with on.click.to(self) so the Phoenix client sends the event to that exact runtime component rather than the owning LiveView.

Each stable instance owns a separate model. Voting in scala-vote therefore does not modify zio-vote.

Map Component Outputs

Emit only from handleMessage; mount, update, view construction, and after-render contexts do not expose this capability. The placement maps every output into a message accepted by its immediate owner, as in the compact example above.

Scala rejects a mapper that returns another owner's message type, and an output-producing component cannot be rendered without a mapper. A child nested inside another component maps to that component's Msg; forwarding to a root LiveView remains explicit at each boundary.

Output delivery is queued. The component finishes its current transition and render first, then the owner handles the mapped message in a separate serialized server-message turn. This matches Phoenix LiveView's mailbox behavior without exposing untyped tuples or process IDs.

Send Props From Parent To Component

Changed props in an ordinary parent render invoke the component's update lifecycle. For an explicit update to an already mounted instance, call ctx.components.sendUpdate:

scala
case Msg.UpdateScalaProps =>
  ctx.components.sendUpdate(ScalaVote, revisedProps).as(updatedParentModel)

update receives the existing component model. Preserve it unless the props represent a deliberate reset:

scala
override def update(props: Props, model: Model, ctx: UpdateContext) =
  ZIO.succeed(
    if props.resetEpoch == model.resetEpoch then model
    else Model(votes = 0, resetEpoch = props.resetEpoch)
  )

sendUpdate to an absent instance is ignored with a warning. Several explicit updates queued before one render use the last props value.

Use Component-Local Capabilities

A component is more than a model and message handler. Its lifecycle contexts expose the same focused tools needed to implement a self-contained UI unit:

  • typed form bindings rendered inside the component deliver component Msg values;

  • ctx.uploads, ctx.streams, ctx.async, and ctx.subscriptions use namespaces scoped to that exact component instance;

  • ctx.client.push and ctx.client.exec queue browser events or commands;

  • ctx.hooks installs dynamic component hooks, while hooks declares static hooks for every instance.

Async completions return through the component's typed async hooks and then handleMessage. Subscription values use the component's typed event hooks and then handleMessage. Neither is routed through root info hooks. Upload, stream, and subscription names may be reused by another component instance without collision. Client effects, async work, and subscriptions are connected-only; upload and rendered-stream configuration may also be created for disconnected rendering.

Start a required component subscription from the Connection.Connected branch of mount or update via connected.subscriptions. Use ctx.subscriptions for local start, replacement, and cancellation controls. Do not also start the same key unconditionally from an initial update after mount. A dormant registration still occupies its key, so start rejects it as a duplicate. Use replace when changed props deliberately change the stream.

A subscription source must acquire per-run handles inside ZStream.scoped or ZStream.unwrapScoped. Do not capture a previously acquired handle: replacement and revival run the stored stream again after the previous run's scope has been released.

These capabilities remain local only where the runtime owns local state. Component flash uses the owning LiveView's ctx.flash, and navigation requested through message-phase ctx.nav navigates or patches the owning socket. It does not create a route or history boundary around the component.

Target Deliberately

For a component-local browser event, specify phx.target(self) alongside the typed binding. The client target and the Scala message target are distinct:

  • on.click.to(instance)(message) routes by stable component class and logical ID, without depending on a numeric client ID;

  • on.click.to(self)(message) selects the current runtime ComponentRef for typed dispatch; phx.target(self) separately emits the client's phx-target;

  • on.click.toComponent(Component)(message) only fixes the accepted component class. Add phx.target(self) or a DomSelector to choose the actual client target according to Phoenix targeting semantics.

Targets are limited to mounted components in the owning LiveView socket. They do not cross into another nested LiveView's socket, and a missing exact target does not queue work for a future mount. Use typed outputs to communicate upward instead of treating selectors or numeric component IDs as an application message bus.

Remove Components Cleanly

Stop rendering an instance to request removal. Before the browser confirms that its component ID was destroyed, Scalive keeps the exact instance dormant: its model and scoped registrations are retained, while active async and subscription workers are interrupted. If rendering reintroduces that instance first, its model returns and an active retained subscription automatically reruns its stored ZStream with fresh internal tokens. Component async tasks do not restart. Old pending subscription values are discarded, and interruption provides no replay or lossless-delivery guarantee.

Once the browser confirms destruction, Scalive drops the instance and its dynamic hooks, removes its upload, rendered-stream, and subscription scopes, and retires its async tasks. A later render of the same class and logical ID is then a fresh mount, not a revival of the old model; its mount may register entirely new subscriptions. Cancelled, completed, or defective retained registrations are not automatically restarted when a dormant instance returns.

Do not retain a ComponentRef, upload snapshots, or other runtime handles after removal. Put durable data in the parent or an application service before hiding the component. A failed component lifecycle fails the active render or message lifecycle; model expected failures as component messages when the UI should recover without taking down the owning socket.

Choose Eventless When There Are No Messages

Extend LiveComponent.Eventless[Props, Model] when a stateful component mounts, updates from props, and renders but cannot receive server messages. Its Msg is Nothing, so server event bindings are rejected and no unreachable handleMessage implementation is required. Use an ordinary render function instead when even component-local state and lifecycle capabilities are unnecessary.

Test Identity And Both Directions

Connected tests should prove that local state is isolated, output attribution uses stable application identity, prop updates preserve local state, and reset restores both parent and component models. Remount tests should also confirm that component state does not leak between socket lifecycles.

The complete voting example is extracted from executable source:

Source
scala
object VoteComponent
    extends LiveComponent.WithOutput[
      VoteComponent.Props,
      VoteComponent.Msg,
      VoteComponent.Model,
      VoteComponent.Output
    ]:
  final case class Props(
    id: String,
    title: String,
    description: String,
    revision: Int,
    resetEpoch: Int)
  final case class Model(votes: Int, resetEpoch: Int)

  enum Msg:
    case Vote
    case Reset

  enum Output:
    case VoteChanged(id: String, votes: Int)

  def mount(props: Props, ctx: MountContext): Task[Model] =
    ZIO.succeed(Model(0, props.resetEpoch))

  override def update(props: Props, model: Model, ctx: UpdateContext): Task[Model] =
    ZIO.succeed(if props.resetEpoch == model.resetEpoch then model else Model(0, props.resetEpoch))

  def handleMessage(props: Props, model: Model, ctx: MessageContext) =
    case Msg.Vote =>
      val updated = model.copy(votes = model.votes + 1)
      ctx.emit(Output.VoteChanged(props.id, updated.votes)).as(updated)
    case Msg.Reset =>
      ctx.emit(Output.VoteChanged(props.id, 0)).as(model.copy(votes = 0))

  def view(props: Signal[Props], model: Signal[Model], self: ComponentRef[Msg]) =
    articleTag(
      cls                        := "docs-vote-card",
      dataAttr("vote-component") := props.map(_.id),
      headerTag(
        div(
          p(cls := "docs-vote-kicker", "Component-local state"),
          h4(props.map(_.title))
        ),
        div(
          cls := "docs-vote-meta",
          code(dataAttr("component-id")   := props.map(_.id), props.map(_.id)),
          span(dataAttr("props-revision") := "", props.map(props => s"props r${props.revision}"))
        )
      ),
      p(cls := "docs-vote-description", props.map(_.description)),
      div(
        cls := "docs-vote-count-row",
        div(
          cls := "docs-vote-metric",
          span(dataAttr("vote-label")   := "", "Votes"),
          strong(dataAttr("vote-count") := "", model.map(_.votes.toString))
        ),
        div(
          cls := "docs-vote-actions",
          button(
            cls := "docs-vote-primary",
            typ := "button",
            phx.target(self),
            on.click.to(self)(Msg.Vote),
            "Vote"
          ),
          button(
            cls := "docs-vote-secondary",
            typ := "button",
            phx.target(self),
            on.click.to(self)(Msg.Reset),
            "Reset"
          )
        )
      )
    )
end VoteComponent

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

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

  def handleMessage(model: Model, ctx: MessageContext) =
    case Msg.ComponentReported(id, votes) =>
      ZIO.succeed(
        model.copy(status = s"$id reported $votes vote${if votes == 1 then "" else "s"}.")
      )
    case Msg.UpdateScalaProps =>
      val revision = model.scalaRevision + 1
      ctx.components
        .sendUpdate(ScalaVote, scalaProps(revision, model.resetEpoch)).as(
          model
            .copy(scalaRevision = revision, status = s"Parent sent Scala props revision $revision.")
        )
    case Msg.Reset =>
      ZIO.succeed(Model.initial.copy(resetEpoch = model.resetEpoch + 1))

  def view(model: Signal[Model]): HtmlElement[Msg] =
    div(
      cls := "docs-voting-components",
      sectionTag(
        cls        := "docs-vote-parent",
        aria.label := "Parent LiveView state",
        div(
          p(cls := "docs-vote-kicker", "Parent-owned state"),
          p(
            dataAttr("vote-status") := "",
            role                    := "status",
            aria.live               := "polite",
            model.map(_.status)
          )
        ),
        button(typ := "button", on.click(Msg.UpdateScalaProps), "Parent updates Scala props")
      ),
      div(
        cls := "docs-vote-grid",
        ScalaVote.render(
          model.map(model => scalaProps(model.scalaRevision, model.resetEpoch)),
          outputToMessage
        ),
        ZioVote.render(
          model.map(model =>
            VoteComponent.Props(
              "zio-vote",
              "ZIO ecosystem",
              "A second stable instance proves that models and output attribution stay isolated.",
              0,
              model.resetEpoch
            )
          ),
          outputToMessage
        )
      )
    )

  private def outputToMessage(output: VoteComponent.Output): Msg = output match
    case VoteComponent.Output.VoteChanged(id, votes) => Msg.ComponentReported(id, votes)
end VotingComponentsExample

object VotingComponentsExample:
  final case class Model(scalaRevision: Int, resetEpoch: Int, status: String)

  object Model:
    val initial = Model(0, 0, "No component has reported a vote.")

  enum Msg:
    case ComponentReported(id: String, votes: Int)
    case UpdateScalaProps
    case Reset

  private val ScalaVote = component(VoteComponent, "scala-vote")
  private val ZioVote   = component(VoteComponent, "zio-vote")

  private def scalaProps(revision: Int, resetEpoch: Int) =
    VoteComponent.Props(
      "scala-vote",
      "Scala language",
      "Parent updates change these props while preserving the component's local vote count.",
      revision,
      resetEpoch
    )

The component subscription example demonstrates exact-instance namespaces and the parent-controlled visibility boundary:

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")

Try both communication directions in the voting components example, then compare fresh destruction with brief dormancy in the component-owned subscriptions example.