Designing Dynamic HTML
The Missing Information
The earliest Scalive experiment was only a model, a tiny HTML DSL, and a renderer that printed an initial result followed by an update. Even at that stage, the central question was already visible: what should a Scala API for dynamic HTML look like?
Phoenix's rendered format separates static fragments from dynamic values. If a
template contains a fixed <h1> around a title that may change, the server can
send the tag once and send later titles in a numbered slot. HEEx knows where
those boundaries are because it is a template language with an explicit
interpolation syntax.
Scalive uses a Scala HTML DSL instead. By the time an HTML constructor receives
a String, the string does not say whether it is a constant or a value read
from the current model. Both of these expressions have the same type:
h1("Products")
h1(model.title)The obvious answer was to mark values derived from the model. I also considered recovering that information with macros, and later tried removing the distinction from the API and finding changes by rendering the whole view again. None of these choices was only about diff encoding. They changed what could be written in a view and how much the renderer had to infer afterwards.
I wanted to keep application state as one ordinary Scala value and use ordinary functions to derive what the page needed. The problem was that, by the time those derived values reached the HTML DSL, the renderer had lost the information it needed to produce a good diff.
Whatever solution I chose also had to work for attributes, conditional branches, and collections. At the same time, I did not want application code to maintain a parallel graph of caches, subscriptions, dirty flags, and cleanup. That machinery felt like the renderer's responsibility. Most of the designs that followed were attempts to preserve just enough information for the renderer without making that machinery part of the application API.
A Function From The Model
The first prototype represented a dynamic value as a function from the model. Stripped only of its throwaway example names, its API was small enough to show directly:
trait LiveView[Model]:
val model = Dyn[Model, Model](identity)
def render: HtmlTag[Model]
object ProductsView extends LiveView[Products]:
def render =
div(
div("Available now"),
model(_.title)
)Conceptually, Dyn[I, O] was little more than I => O. Calling
model(_.title) composed another function onto the root model projection. The
HTML builder could therefore tell that the nested div was static while the
title had to be evaluated for each model.
For text, this worked well. The model stayed typed, dynamic positions were obvious, and the renderer could construct the HTML shape once. Storing the dynamic values in traversal order also happened to line up neatly with Phoenix's numbered slots.
The trouble started when the shape itself needed to change. A slot number says where a value appears in one compiled shape. It says nothing about a conditional element that disappears or a collection entry that moves while keeping its own dynamic values and event bindings. Position was enough for scalar text, but I needed something else before conditionals and collections could work reliably.
Putting State In Dyn
My next attempts gave dynamic values more responsibility. In one version, each
Dyn carried a key into an assigns-style state map. Changing an assign marked
the corresponding projections as dirty. In another, more Laminar-inspired
version, a root Var owned the current model and mapped Dyn values retained
their previous result:
val model = Var(initialModel)
mainTag(
h1(model(_.title)),
model(_.products).splitBy(_.id) { (_, product) =>
articleTag(product(_.name))
}
)Now a mapped value could remember whether its output had changed, so the renderer
did not have to evaluate every projection. splitBy could also retain one
projected row for each domain key. Reordering products no longer meant pretending
that the product at index zero was the same product as before.
The API looked appealing because it resembled a client-side reactive library, but the implementation responsibilities were very different. These values did not have a browser component lifecycle around them. Making them observable would have required subscription ownership and cleanup, so the renderer instead propagated changes through explicit internal synchronization. It had to update parents before children, reset changed flags after a successful diff, and retain or discard keyed values at exactly the right moment.
That bookkeeping was hidden from application code, but it was still encoded in
the same mutable objects exposed by the application-facing abstraction. The
state identity and render identity were too closely coupled. It was also
possible to place a correctly typed Dyn from the wrong state root into a view,
an error the type parameters could not distinguish.
I still liked one part of this design: only the application can say what makes a collection entry the same entry, so asking for a key was the right call. What I no longer liked was storing retained render state in mutable objects that looked like application values. That state belonged in the renderer.
Rendering From A Plain Model
The cleanest way to remove the problems around Dyn was to remove Dyn itself:
def view(model: Products): HtmlElement =
mainTag(
h1(model.title),
model.products.map { product =>
articleTag(product.name)
}
)I liked this API immediately. The model is an ordinary value. Conditionals use
if and match, collections use map, and helper methods accept whatever types
make sense for the application. There is no separate vocabulary for reading
state inside a view and no dynamic value that can belong to the wrong root.
The renderer can support that API by invoking view for every model revision,
normalizing the new HTML tree, and comparing it with the previous tree. Dynamic
HTML becomes whatever differs between those two results. Stable collection keys
can remain explicit as an optimization and as a statement of row identity, but
ordinary scalar values need no marker.
For a while, this looked like the ideal division of responsibility: application code writes unconstrained Scala, and the runtime works out the rest.
The cost is that the API has erased information the renderer eventually needs. Before discovering that one title changed, the runtime must call the complete view, allocate its elements and modifiers, rebuild collection projections, and compare the result. A newly allocated event-handler closure must be matched with the binding that occupied the equivalent place in the previous tree. Structural position becomes identity again, only now the relationship is recovered after rendering instead of declared while building the view.
Some of that work can be optimized with fingerprints, memoization, and careful tree differencing. Those techniques improve the implementation, but they do not restore the semantic information removed from the API. The renderer is still trying to infer which computations are stable, which values are dependencies, and which newly created objects represent an existing logical node.
I kept this version because writing views with it was pleasant. Over time it became clear that removing staging from the method signature had only moved it into tree reconstruction and identity heuristics. The syntax was simpler because the renderer was doing more guessing.
Back To Explicit Staging
The current API returns to an explicit dynamic value:
def view(model: Signal[Products]): HtmlElement[Msg] =
mainTag(
h1(model.map(_.title)),
model.map(_.products).splitBy(_.id) { (_, product) =>
articleTag(
product.map(_.name),
button(on.click(product)((value, _) => Msg.Select(value.id)), "Select")
)
}
)This deliberately circles back to the original Dyn. A signal is again a staged
projection, and placing it in the HTML tree tells the renderer that the value
belongs to a dynamic slot. What changed was the amount of responsibility carried
by that projection.
Signal[A] is read-only. Its map and zip operations are intended for pure
transformations; it provides no operation for setting a value, sampling the
current value, subscribing, or running an effect. Scala cannot prevent a
function passed to map from performing a side effect, but Scalive may skip or
reuse that transformation, so doing so would make the view incorrect. A signal
is a description of how to derive an A during rendering, not another
application state container and not a general-purpose reactive stream.
Because a signal cannot be sampled or mutated through the public API, the renderer can construct the view graph once. Ordinary values in that graph are static for its lifetime; signals identify the computations that must be sampled against a candidate model. Reusing one signal expression in multiple places does not duplicate its transformation work. If its dependencies have not changed, the transformation does not need to run at all.
Structural operations make the same staging explicit where an ordinary scalar
slot is not enough. when, choose, and option describe the finite ways a
subtree can vary. splitBy says both that a collection changes and which domain
value identifies each retained row. The renderer owns the compiled branches,
row scopes, caches, and cleanup behind those operations.
The application still knows what each expression means while constructing this graph. Keeping that information is the point of returning to an explicit type.
Why Signal Is Limited
The signal API gives up some of the plain-model version's freedom. This ordinary Scala conditional cannot inspect a signal:
if model.showDetails then details(model) else summary(model)The staged equivalent must state that the branch follows the model:
model.map(_.showDetails).choose(
details(model),
summary(model)
)This is more syntax, and helper functions often need to accept signals instead of plain values. I considered that a real cost. Still, the condition genuinely changes the shape of the rendered HTML. Writing it explicitly lets Scalive construct both shapes once and switch between them without rerunning arbitrary view code.
Collections need one additional piece of information. The application supplies
the domain key to splitBy, and the renderer owns the retained row state behind
it. A stable HTML id is a separate choice for cases where the browser must
preserve the exact DOM node across a move.
The Macro Route I Did Not Take
I considered using macros to keep the plain-model API while identifying dynamic
fragments during compilation. In principle, that could have allowed arbitrary
Scala in view(model) while rewriting values derived from model into staged
nodes for the renderer.
I never built a prototype. The implementation and maintenance complexity already looked substantial, especially once helper methods and ordinary function composition were involved. More importantly, it felt too magical and indirect for my taste. Reading a view would no longer tell the whole story because the compiler would be introducing the dynamic boundary somewhere out of sight.
I was also worried about compile times if every view required that analysis and rewriting. Since I did not prototype the approach, I do not have measurements; it was one more reason not to take on the complexity.
Signals leave the boundary in ordinary Scala code. Static and dynamic values are visibly different where the distinction matters, without introducing a separate template language or an invisible compiler transformation.
Where The Boundary Ended Up
The current design is built around replacing one strongly typed model as the LiveView's application state rather than mutating render values in place. The runtime provides a read-only signal for projecting that model into HTML. Application code declares dynamic dependencies and semantic structure; the renderer owns evaluation, retention, internal identity, and lifecycle cleanup.
In the end I came back quite close to the first experiment, but with a narrower
role for the dynamic value. A Signal marks a dependency. It does not remember
whether it changed, identify a rendered node, or own anything that needs to be
cleaned up. All of that stays inside the renderer.
Some views would be shorter with a plain model. I accept the extra map,
choose, and splitBy calls because they make the model dependency visible when
reading the view. They also give the renderer information that would otherwise
have to be recovered from a new tree or introduced by a compiler transformation.
For how the resulting API behaves in application code, read Rendering, bindings, and diffs. For the retained renderer beneath it, read Runtime architecture.