Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline

Stateful components and communication

Prerequisites

Start with a LiveView that binds typed events and keeps rendered state in its model. Read Typed forms and validation before moving a form into a component.

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

A component has 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 report domain events to the immediate owner.

Declare an output-producing component with LiveComponent.WithOutput:

scala
object VoteComponent
    extends LiveComponent.WithOutput[Props, Msg, Model, Output]:
  enum Msg:
    case Vote
    case Reset

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

Use the ordinary three-parameter LiveComponent when the component has no outputs. Its output type is Nothing, so it retains the simpler render(props) placement API.

Keep Local Events Local

Bindings rendered by a component deliver its Msg values to that exact component. Target self explicitly when a binding needs the current runtime component reference:

scala
button(on.click.to(self)(Msg.Vote), "Vote")

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:

scala
case Msg.Vote =>
  val updated = model.copy(votes = model.votes + 1)
  ctx.emit(Output.VoteChanged(props.id, updated.votes)).as(updated)

The placement maps every output into a message accepted by its immediate owner:

scala
ScalaVote.render(
  scalaProps(model),
  output => output match
    case VoteComponent.Output.VoteChanged(id, votes) =>
      Msg.ComponentReported(id, votes)
)

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, and ctx.async 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 as component messages. Upload and stream names may be reused by another component instance without collision. Client effects and async work are connected-only; upload and stream configuration may also be created for disconnected rendering.

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

Ordinary typed bindings in a component subtree are wrapped for the current component automatically. Prefer that default. For an event rendered elsewhere:

  • 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) emits phx-target for the current runtime ComponentRef;

  • 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 remove it. Once the browser confirms that its component ID was destroyed, Scalive drops the instance and its dynamic hooks, removes its upload and stream scopes, and interrupts its async tasks. A later render of the same class and logical ID is therefore a fresh mount, not a revival of the old model.

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

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

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

Try both communication directions in the voting components example.