Skip to main content

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

MethodDatastar AttributeDescription
dataSignals(signal)data-signals:nameDeclare one keyed signal with an initial value
dataSignals(update, updates...)data-signalsPatch multiple signals with one object expression
dataBind(signal)data-bind:nameTwo-way bind an input to a signal
dataTextdata-textSet element text content
dataShowdata-showConditionally show/hide element
dataClass("name")data-class:nameToggle CSS class
dataOn.clickdata-on:clickEvent handler
dataComputed(signal)data-computed:nameComputed signal
dataEffectdata-effectSide-effect expression
dataIndicator(signal)data-indicator:nameLoading indicator signal
dataRef("name")data-ref:nameElement reference
dataInitdata-initInitialization 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:

ModeDescription
OuterMorphs the target element in place (default, omitted from SSE)
InnerReplaces the target's children
ReplaceReplaces the target element
PrependInserts before the first child
AppendInserts after the last child
BeforeInserts before the target element
AfterInserts after the target element
RemoveRemoves 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.