sse.md

August 28, 2026 · View on GitHub

DatastarEvent builds the events a server sends to patch a live page: replace elements, patch signals, execute a script, or remove elements. Each constructor returns a builder for its event kind, and DatastarEvent#renderSSE produces the wire format. DatastarEvent.executeScript is the exception noted below: it reuses the element-patch builder, so it offers two options that do not apply to it. Supporting types: PatchElementsBuilder, PatchSignalsBuilder, RemoveElementsBuilder, ElementPatchMode, EventType. The constructors and the one method they all end in:

sealed trait DatastarEvent {
  def renderSSE: String
}

object DatastarEvent {
  def patchElements(elements: Dom): PatchElementsBuilder
  def patchSignals(first: SignalUpdate[_], rest: SignalUpdate[_]*): PatchSignalsBuilder
  def patchSignalsRaw(json: String): PatchSignalsBuilder
  def executeScript(code: Js): PatchElementsBuilder
  def removeElements(selector: CssSelector): RemoveElementsBuilder
}

Motivation

The Datastar response protocol is SSE with structure inside the data: field. A patch looks like this on the wire:

event: datastar-patch-elements
data: selector #results
data: mode append
data: elements <li>Widget</li>

Three things make that easy to get wrong by hand. The event name must match the protocol exactly. The data: lines are a small keyed format, not free text, and the keys differ per event kind. And the terminating blank line is required — omit it and the browser waits indefinitely for an event it already has.

DatastarEvent removes all three concerns. Each constructor knows its event name, each builder method maps to one protocol field, and DatastarEvent#renderSSE delegates the SSE envelope to ServerSentEvent, which supplies the terminator.

Quick Showcase

Build an event and render it:

import zio.blocks.html._
import zio.http.datastar._

val count = Signal[Int]("count")

A signal patch is one line of data:

DatastarEvent.patchSignals(count := 42).renderSSE

An element patch carries its target and mode:

DatastarEvent
  .patchElements(li("Widget"))
  .selector(CssSelector.id("results"))
  .mode(ElementPatchMode.Append)
  .renderSSE

Patching Elements

DatastarEvent.patchElements takes a Dom and returns a PatchElementsBuilder. Its options map one-to-one onto protocol fields:

MethodFieldDefault
selector(CssSelector)selectoromitted — patch by element id
mode(ElementPatchMode)modeOuter, which is omitted
viewTransitionuseViewTransition trueomitted
namespace(String)namespaceomitted
eventId(String)SSE id:omitted
retry(Long)SSE retry:omitted

Every option is omitted when unset, so the minimal event is just the elements:

import zio.blocks.html._
import zio.http.datastar._

val row = li("Widget")

With no selector, Datastar matches on the element's own id, and mode is absent because Outer is the default:

DatastarEvent.patchElements(row).renderSSE

Adding options adds exactly the corresponding lines:

DatastarEvent.patchElements(row).selector(CssSelector.id("list")).viewTransition.eventId("evt-1").renderSSE

:::note[mode is omitted when it is Outer] ElementPatchMode.Outer is the protocol default, so setting it explicitly produces no mode line. That is intentional and keeps the payload minimal; do not read the absence of a mode line as "no mode". :::

ElementPatchMode

Eight modes decide where the content lands relative to the target. Each renders as its lowercase name:

ModeEffect
OuterReplace the target element itself (default)
InnerReplace the target's children
ReplaceReplace using replacement semantics
PrependInsert before the target's existing children
AppendInsert after the target's existing children
BeforeInsert immediately before the target
AfterInsert immediately after the target
RemoveRemove the target

Append is the one to reach for when adding to a list without re-rendering it:

ElementPatchMode.Append.render
ElementPatchMode.Inner.render

Patching Signals

DatastarEvent.patchSignals takes one or more SignalUpdates and renders them as a single JSON object under the signals key:

import zio.blocks.html._
import zio.http.datastar._

val price    = Signal[Double]("price")
val quantity = Signal[Int]("quantity")

Multiple updates go in one event rather than one event each:

DatastarEvent.patchSignals(price := 19.99, quantity := 3).renderSSE

PatchSignalsBuilder#onlyIfMissing adds onlyIfMissing true, which tells Datastar to set the signal only when it is not already present — the way to supply a default without clobbering client state:

DatastarEvent.patchSignals(price := 0.0).onlyIfMissing.renderSSE

DatastarEvent.patchSignalsRaw — pre-serialized JSON

When the JSON already exists — from a cache, or a shape no Schema describes — DatastarEvent.patchSignalsRaw takes it verbatim:

DatastarEvent.patchSignalsRaw("""{"count":7}""").renderSSE

:::warning[patchSignalsRaw does not validate] The string is inserted into the payload unchanged. Malformed JSON produces an event the browser silently discards, and there is no server-side error. Prefer DatastarEvent.patchSignals with typed updates, which cannot produce invalid JSON. :::

Executing Scripts

DatastarEvent.executeScript takes a Js and returns a PatchElementsBuilder — because on the wire it is an element patch, appending a <script> element that the browser executes:

import zio.blocks.html._
import zio.http.datastar._

The rendered event shows the mechanism rather than hiding it:

DatastarEvent.executeScript(js"console.log('done')").renderSSE

The script is appended to body and carries data-effect="el.remove()", so it deletes itself after running and leaves no residue in the DOM.

Because the return type is PatchElementsBuilder, the element-patch options are all available — including selector and mode, which would override that targeting and rarely make sense here. Treat the builder as offering PatchElementsBuilder#eventId and PatchElementsBuilder#retry.

Removing Elements

DatastarEvent.removeElements takes a CssSelector and returns a RemoveElementsBuilder with a deliberately narrow surface — viewTransition, namespace, RemoveElementsBuilder#eventId, and RemoveElementsBuilder#retry:

DatastarEvent.removeElements(CssSelector.id("banner")).renderSSE

There is no mode or selector method, because the selector is the argument and the mode is fixed to remove with an empty element body. That is the builder pattern doing its job: an option that would be meaningless is not offered.

EventType

EventType names the protocol event in the SSE event: field. Two values, and you rarely name them directly since each constructor selects the right one:

ValueRenders
EventType.PatchElementsdatastar-patch-elements
EventType.PatchSignalsdatastar-patch-signals

Both render the protocol string rather than the Scala name:

EventType.PatchElements.render
EventType.PatchSignals.render

Both element patches and script execution use PatchElements, and removal does too — the distinction between them is in the data: body, not the event name.

Streaming Several Events

DatastarEvent#renderSSE produces one complete event including its terminating blank line, so a stream is a concatenation. Nothing in the module manages the stream itself; you write the strings to whatever response body your HTTP layer uses:

import zio.blocks.html._
import zio.http.datastar._

val progress = Signal[Int]("progress")

val stream: String =
  (1 to 3).map(step => DatastarEvent.patchSignals(progress := step * 33).renderSSE).mkString

Each event is self-delimiting, so concatenating what DatastarEvent#renderSSE returns is valid SSE:

stream

Set PatchElementsBuilder#eventId when the client should be able to resume with Last-Event-ID, and PatchElementsBuilder#retry to control the reconnection delay — both are ordinary SSE fields handled by ServerSentEvent.

Integration Points

DatastarEvent#renderSSE delegates to ServerSentEvent from zio-http-model, which supplies the SSE envelope, the field ordering, and the terminating blank line — see ServerSentEvent. The Datastar-specific part is the event name and the keyed data: body.

Element patches carry Dom values and CssSelector targets from HTML, and signal patches carry SignalUpdate values whose JSON comes from the type's Schema. What the patched page does with the result is determined by the Attributes and Event Handlers rendered into it.