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 LiveComponenttrait LiveComponent[Props, Msg, Model] 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.
private val ScalaVote = component(VoteComponent, "scala-vote")
private val ZioVote = component(VoteComponent, "zio-vote")The ID is application identity within that component class. An
ComponentRefopaque type ComponentRef >: ([Msg] =>> Nothing) <: ([Msg] =>> Any) = ([Msg] =>> ComponentTarget) 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:
Propsare values supplied by the owner.Msgvalues are inputs handled by the component.Modelis state isolated to one component instance.Outputvalues report domain events to the immediate owner.
Declare an output-producing component with
LiveComponent.WithOutputtrait WithOutput[Props, Msg, Model, Output0] extends LiveComponent[Props, Msg, Model]:
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:
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:
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:
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.sendUpdatedef sendUpdate[C <: LiveComponent[?, ?, ?]](id: String, props: LiveComponent.PropsOf[C])(using evidence$1: reflect.ClassTag[C]): zio.package.Task[Unit]:
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:
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
Msgvalues;ctx.uploads,ctx.streams, andctx.asyncuse namespaces scoped to that exact component instance;ctx.client.pushandctx.client.execqueue browser events or commands;ctx.hooksinstalls dynamic component hooks, whilehooksdeclares 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)emitsphx-targetfor the current runtimeComponentRef;on.click.toComponent(Component)(message)only fixes the accepted component class. Addphx.target(self)or aDomSelectorto 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:
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
)View source (documentation/site/src/scalive/docs/examples/VotingComponentsExample.scala:8-171)
Try both communication directions in the voting components example.
Related Tasks
Use Nested LiveViews for independent socket ownership, sticky navigation, and crash isolation.
Build component forms with Typed forms and validation.
Add component-owned files with File uploads.
Manage finite component work with Asynchronous work and subscriptions.
Compose client effects with Browser commands, events, and hooks.