Datastar
zio-blocks-datastar provides a type-safe Scala SDK for Datastar, the hypermedia framework that brings reactive UIs via server-sent events (SSE). It builds on zio-blocks-html for DOM construction and zio-blocks-schema for JSON serialization.
Installation
// JVM
libraryDependencies += "dev.zio" %% "zio-blocks-datastar" % "0.0.41"
// Scala.js
libraryDependencies += "dev.zio" %%% "zio-blocks-datastar" % "0.0.41"
Signals
A Signal[A] represents a named reactive signal on the client. Use := to pair it with a value, producing a SignalUpdate ready for SSE transmission:
import zio.http.datastar._
import zio.blocks.schema.Schema
case class User(name: String, age: Int)
object User {
implicit val schema: Schema[User] = Schema.derived
}
val count = Signal[Int]("count")
val username = Signal[String]("username")
// Create signal updates (serializes via Schema's cached JSON codec)
val update1 = count := 0
val update2 = username := "Alice"
SSE Events
DatastarEvent is the sealed trait representing events sent to the browser. Use the companion object's builder methods:
patchSignals
Send signal updates to the client:
val sse = DatastarEvent
.patchSignals(count := 42, username := "Bob")
.renderSSE
// With options:
val sseWithOptions = DatastarEvent
.patchSignals(count := 1)
.withOnlyIfMissing
.withEventId("evt-1")
.withRetry(5000)
.renderSSE
patchElements
Send DOM fragments to the client:
import zio.blocks.html._
val sse = DatastarEvent
.patchElements(div(id := "status")("Online"))
.withSelector(CssSelector.id("status"))
.withMode(ElementPatchMode.Inner)
.withViewTransition
.renderSSE
executeScript
Run JavaScript on the client:
val sse = DatastarEvent
.executeScript(js"console.log('hello')")
.renderSSE
removeElements
Remove elements matching a CSS selector:
val sse = DatastarEvent
.removeElements(CssSelector.id("old-item"))
.renderSSE
Attribute DSL
Import zio.http.datastar._ to get all data-* attribute constructors. These integrate with the zio-blocks-html DSL:
import zio.blocks.html._
import zio.http.datastar._
val count = Signal[Int]("count")
val username = Signal[String]("username")
div(
dataSignals(count := 0),
dataText := count
)(
span()("Count: "),
button(dataOn.click := js"$count++")("Increment")
)
Datastar expression positions are intentionally stricter than generic HTML/JS templating.
Raw String values are rejected in Datastar expression attributes; use js"..."
for expressions, or typed values like Signal, SignalUpdate, and DatastarRef.
dataText := count
dataText := count.ref
dataOn.click := js"$count++"
// does not compile:
// dataOn.click := "$count++"
Available Attributes
| Method | Datastar Attribute | Description |
|---|---|---|
dataSignals(signal) | data-signals:name | Declare one keyed signal with an initial value |
dataSignals(update, updates...) | data-signals | Patch multiple signals with one object expression |
dataBind(signal) | data-bind:name | Two-way bind an input to a signal |
dataText | data-text | Set element text content |
dataShow | data-show | Conditionally show/hide element |
dataClass("name") | data-class:name | Toggle CSS class |
dataOn.click | data-on:click | Event handler |
dataComputed(signal) | data-computed:name | Computed signal |
dataEffect | data-effect | Side-effect expression |
dataIndicator(signal) | data-indicator:name | Loading indicator signal |
dataRef("name") | data-ref:name | Element reference |
dataInit | data-init | Initialization expression |
Event Modifiers
Chain modifiers on dataOn before assigning a handler:
// Debounce input by 300ms
dataOn.input.debounce(300) := username
// Click with prevent default, only once
dataOn.click.prevent.once := js"handleSubmit()"
// Throttle scroll, listen on window
dataOn.scroll.throttle(100).window := js"onScroll()"
Available modifiers: debounce, debounceLeading, throttle, throttleLeading, delay, once, passive, capture, stop, prevent, outside, window, document, viewTransition.
Case Modifiers
dataOn and dataSignals(signal) builders expose .camel, .kebab, .snake, and .pascal to control how the attribute key is cased. The default for dataOn is kebab; for dataSignals(signal) it is camel.
// Renders data-on:myCustomEvent (camel, explicit)
dataOn("myCustomEvent").camel := js"handleIt()"
// Renders data-signals:my-signal__case.kebab (kebab override)
dataSignals(count := 0).kebab
The suffix __case.<modifier> is appended to the attribute name only when the chosen case differs from the builder's default.
Advanced Triggers
dataOnIntersect
Fires when the element enters (or exits) the viewport. Modifiers map directly to Datastar's intersection observer options:
// Fire once when 50% of the element is visible
div(dataOnIntersect.half.once := js"loadContent()")()
// Fire when element exits the viewport, debounced
div(dataOnIntersect.exit.debounce(200) := js"cleanup()")()
Available modifiers: once, half, full, exit, threshold(pct: Double), delay(millis), debounce(millis), throttle(millis), viewTransition.
dataOnInterval
Runs an expression on a repeating timer:
// Poll every second
div(dataOnInterval.duration(1000) := js"$count++")(count.ref.text)
// Leading-edge interval with view transitions
div(dataOnInterval.durationLeading(500).viewTransition := js"refresh()")()
dataOnSignalPatch / dataOnSignalPatchFilter
dataOnSignalPatch runs whenever the server patches signals. Use dataOnSignalPatchFilter to restrict which patches trigger the handler:
div(
dataOnSignalPatch.debounce(100) := js"onPatch()",
dataOnSignalPatchFilter := js"$count > 0"
)()
Available modifiers on dataOnSignalPatch: delay(millis), debounce(millis), throttle(millis).
Style and Attribute Binding
dataStyle
Bind inline styles, either as a whole-object expression or per-property:
// Keyed: sets a single CSS property
div(dataStyle("color") := js"$isDark ? 'white' : 'black'")()
// Unkeyed: bind an entire style object
div(dataStyle := js"{color: $color, fontSize: $size + 'px'}")()
dataAttr
Bind arbitrary HTML attributes:
div(dataAttr("aria-label") := js"$label")()
div(dataAttr("tabindex") := js"$isActive ? 0 : -1")()
Morph Control
Mark elements so Datastar's morphing algorithm treats them specially:
// Exclude element and its children from morphing
div(dataIgnore)()
// Exclude only the element itself, not its children
div(dataIgnoreSelf)()
// Prevent the element from being morphed (keep existing DOM node)
div(dataIgnoreMorph)()
// Preserve a specific attribute across morphs
input(dataPreserveAttr("value"))()
JSON Signals
dataJsonSignals passes a raw JSON string as the signals expression, useful when signals are serialized server-side:
div(dataJsonSignals := js"""{"count":0,"username":""}""")()
ElementPatchMode
Controls how patchElements applies DOM content to the target:
| Mode | Description |
|---|---|
Outer | Morphs the target element in place (default, omitted from SSE) |
Inner | Replaces the target's children |
Replace | Replaces the target element |
Prepend | Inserts before the first child |
Append | Inserts after the last child |
Before | Inserts before the target element |
After | Inserts after the target element |
Remove | Removes the target element |
DatastarEvent
.patchElements(li(id := "new")("item"))
.withSelector(CssSelector.id("list"))
.withMode(ElementPatchMode.Append)
.renderSSE
CssSelector
CssSelector (from zio.blocks.html) constructs CSS selectors with type-safe combinators:
import zio.blocks.html._
CssSelector.id("main") // #main
CssSelector.`class`("active") // .active
CssSelector.element("div") // div
CssSelector.raw(".foo > .bar") // .foo > .bar
CssSelector.universal // *
Selectors compose with operators:
val sel = CssSelector.element("ul") > CssSelector.element("li") // ul > li
val grouped = CssSelector.id("a") | CssSelector.id("b") // #a, #b
Signal.dynamic
Signal[A]("name") validates the signal name at compile time for string literals. Use Signal.dynamic[A](name) when the name is only known at runtime:
val fieldName = computeFieldName() // runtime string
val sig = Signal.dynamic[String](fieldName) // validated at runtime
sig := "hello" // works like any other Signal
dynamic throws IllegalArgumentException if the name is invalid. The same validation rules apply: dot-separated JavaScript identifiers, no __.
SSE Streaming
DatastarEvent has no ZIO or HTTP framework dependency. It just produces strings. To stream events, set the response content-type to text/event-stream and write each renderSSE result to the response body:
// Framework-agnostic pseudocode
response.setHeader("Content-Type", "text/event-stream")
response.setHeader("Cache-Control", "no-cache")
// Send an initial signal patch
response.write(DatastarEvent.patchSignals(count := 0).renderSSE)
// Stream DOM updates as data changes
val fragment = div(id := "result")(computedContent)
response.write(
DatastarEvent
.patchElements(fragment)
.withSelector(CssSelector.id("result"))
.renderSSE
)
Each renderSSE call returns a self-contained SSE block — multiple events can be written sequentially to the same stream.