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 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
The component APIs separate four independent roles:
Propsare values supplied by the owner.Msgvalues are inputs handled by the component.Modelis state isolated to one component instance.Outputvalues, when declared, report domain events to the immediate owner.
Only an output-producing component declares Output, through
LiveComponent.WithOutputtrait WithOutput[Props, Msg, Model, Output0] extends LiveComponent[Props, Msg, Model].
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:
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.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,ctx.async, andctx.subscriptionsuse 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 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.subscriptionsdef subscriptions: Subscriptions[Msg].
Use ctx.subscriptionsdef subscriptions: Subscriptions[Msg]
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 runtimeComponentReffor typed dispatch;phx.target(self)separately emits the client'sphx-target;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 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:
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
)View source (documentation/site/src/scalive/docs/examples/VotingComponentsExample.scala:8-171)
The component subscription example demonstrates exact-instance namespaces and the parent-controlled visibility boundary:
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)
Try both communication directions in the voting components example, then compare fresh destruction with brief dormancy in the component-owned subscriptions 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, subscriptions, and connected resources.
Compose client effects with Browser commands, events, and hooks.