# ZIO Blocks > Type-safe, modular building blocks for Scala This file contains all documentation content in a single document following the llmstxt.org standard. ## ADR-001: SQL Data Migration Architecture ## Status Accepted ## Context The `zio-blocks` library needed an online data migration system that allows evolving database schemas from application code. The system builds on two existing foundations: - `zio.blocks.schema.migration.Migration[A, B]` for typed schema evolution - `zio.blocks.sql.Repo[E, ID]` for database access Key requirements: - No hand-written SQL in migrations - No XML or Liquibase-style migration files - Support for both PostgreSQL and SQLite - Safe cutover for large tables with zero data loss The module is Scala 3 only. ## Decisions ### Decision 1: Three Execution Models **Decision:** Provide three execution tiers, each suited to a different scale of change: - **A-Tiny** (`TinyMigrator`): Simple startup DDL via `Transactor.transact`. For schema-only changes that are fast and non-blocking. - **A-Small** (`SmallMigrator`): Queue-based batch worker for moderate tables. Lifecycle: `init()` then loop `processBatch()` then `complete()`. - **B-Large** (`LargeMigrator`): Incremental worker with safe completion protocol. Lifecycle: `init()` then `fence()` then `drain()` then `complete()`. **Rationale:** Different migration scales need different guarantees. Tiny is lightweight and runs at startup. Small is transactional and batch-oriented. Large has distributed coordination and a safe cutover protocol. A single execution model would either be too heavy for simple DDL changes or too weak for large-table migrations. ### Decision 2: Coalesced Dirty-Key Queue **Decision:** The queue table is a single shared table with columns `(id, op, payload)` — `id` is the source primary key (TEXT, PRIMARY KEY so duplicate keys coalesce via upsert), `op` records the capturing operation (`I`/`U`/`D`), and `payload` optionally stores the row JSON on deletes. There is no per-migration queue table or `migration_id` column; the source table remains authoritative. Keys enter the queue either manually (`QueueTable.enqueue`) or automatically via opt-in capture triggers (`captureTriggers = true` installs them in `init()`); trigger installation is idempotent and requires PG 14+ on PostgreSQL. **Rationale:** Bounded queue storage. A single keyed table keeps the worker protocol simple and lets manual enqueue and trigger capture share one mechanism. The source row is always reread at processing time for `I`/`U` entries, avoiding stale data; `D` entries carry the payload needed to resolve the target key. Coalescing on the primary key means multiple updates to the same row between processing cycles collapse into a single queue entry. Manual enqueue keeps the default behavior dependency-free; triggers let producers write normally without application-code changes, but must not be enabled when the migrator writes to the same physical table it captures from (self-requeue loop). ### Decision 3: Source-Authoritative Replay **Decision:** Workers claim a dirty key and, in one transaction: reread the source row (without taking a source-row lock), apply the migration, write the target, and remove the key. A missing source row at processing time means delete the target row. **Rationale:** Prevents deadlock with concurrent source writers. The worker holds only the dirty-key claim, not the source row lock. A source writer can proceed without waiting for the worker to finish reading. The source table is the single source of truth, so rereading at processing time guarantees the worker acts on the latest state. ### Decision 4: Target Strategy Orthogonality **Decision:** `TargetStrategy` is a thin enum (`InPlace` | `ShadowTable(suffix)`) orthogonal to the execution model. ShadowTable uses `CREATE TABLE IF NOT EXISTS ... LIKE ... INCLUDING ALL` and a DDL swap at completion. **Rationale:** Decouples "how to write" from "when to write." The same worker code works for both strategies. InPlace updates rows directly in the source table. ShadowTable writes to a separate table and swaps it in at cutover. Callers choose the strategy based on whether the schema change is backward compatible. ### Decision 5: Safe Completion Protocol (B-Large) **Decision:** State machine: `Initialized -> Fenced -> Drained -> Completed`. Fenced state prevents new source writes. Drain processes remaining queue items. Complete swaps the shadow table. **Rationale:** Guarantees zero data loss during cutover. Queue persistence across restarts enables crash recovery. State is checked at each transition with `require()` guards. The protocol ensures every row modified between `init()` and `complete()` is migrated, even if the application crashes and restarts mid-migration. ### Decision 6: PostgreSQL-Specific Features **Decision:** `QueueTable.dequeue` uses `SELECT ... FOR UPDATE SKIP LOCKED` for concurrent workers on PostgreSQL. Shadow table uses `CREATE TABLE ... LIKE ... INCLUDING ALL`. DDL swap is atomic. **Rationale:** PostgreSQL's row-level locking and DDL transactionality enable safe multi-worker migrations. SQLite uses `dequeueSQLite` without `SKIP LOCKED` since SQLite serializes writes via `BEGIN IMMEDIATE` (single-writer model). The dialect split is necessary because the two databases have fundamentally different concurrency models. ### Decision 7: ID Type Parameters with Equality Constraint **Decision:** `LargeMigrator` and `SmallMigrator` accept separate `ID1` and `ID2` type parameters, but construction requires evidence that `ID1 =:= ID2`. The queue stores V1 IDs. **Rationale:** The two type parameters document the roles (source-repo ID vs target-repo ID) and keep repo signatures precise, while the `ID1 =:= ID2` constraint makes delete propagation type-safe: when a source row disappears between dequeue and read, the worker deletes the corresponding target row using the same key, reinterpreted through the evidence (`ev.substituteCo`) rather than a cast. Genuinely different key types (for example, `Int` to `Long`) are not supported by the built-in migrators; callers needing that must provide a custom conversion path outside this module. ### Decision 8: Pre-Commit State Enforcement **Decision:** `complete()` requires `Drained` state. `drain()` requires `Fenced` state. `fence()` requires `Initialized` state. `drain()` only transitions to `Drained` if not paused. **Rationale:** Prevents misordered lifecycle calls. The pause guard prevents `complete()` from being called when the queue is not empty (paused mid-drain). The stall valve turns an unbounded hot loop (empty batches with pending items, e.g. a stuck concurrent worker holding row locks) into a clear failure after 100 consecutive rounds; `SmallMigrator.complete()` likewise refuses while items are pending. State transitions are enforced at runtime with `require()` checks, making invalid sequences fail fast rather than producing subtle data inconsistencies. ### Decision 9: Failure Policy **Decision:** On worker transaction failure: rollback, retain the dirty key, return failure to caller. No automatic retry, no dead-letter queue. **Rationale:** Simple, predictable semantics. The caller decides retry policy. No silent data loss. No configuration surface for retry counts, backoff strategies, or dead-letter routing. The dirty key remains in the queue, so the row can be retried by calling `processBatch()` or `drain()` again. ### Decision 10: Scope Exclusions **Decision:** The following are explicitly out of scope: - No schema-diff or automatic migration derivation (Derivation.scala removed per review) - No CLI (MigrationCLI removed per review) - No JSON or full-row outbox payloads - No external brokers or LISTEN/NOTIFY - No composite keys, configurable retry, configuration files, third-party dialects, or automatic target-schema DDL generation **Rationale:** Keeps scope narrow. `Migration[A, B]` provides the typed transformation. `Repo` provides the database surface. The data migration module is a thin orchestration layer. Callers supply the migration logic; the module handles queueing, worker coordination, and cutover. ## Consequences ### Positive - Clean separation of concerns: `Migration[A, B]` (what) vs execution model (how) vs target strategy (where) - Queue-based dirty-key tracking is storage-bounded and crash-recoverable - Safe completion protocol prevents data loss during cutover - ID type flexibility supports realistic migration scenarios - No hand-written SQL, no XML, no external dependencies beyond the database ### Tradeoffs - Missing-source-row delete relies on ID type compatibility (`ID1 =:= ID2`). For genuinely different ID types, a conversion function would be needed. - No automatic retry requires caller-side error handling. Callers must implement their own retry logic if desired. - PostgreSQL-only `SKIP LOCKED` limits full multi-worker support. SQLite is single-worker due to its write serialization model. - State is in-memory (not persisted). Restart resets the state machine, though the queue table persists across restarts for crash recovery. - Per-key writer blocking during worker processing is accepted. A source writer waits while the worker holds the dirty-key claim. No tuning configuration is provided for this. ## References - [Data Migration Reference Documentation](../reference/data-migration.md) - PR #1534 (implementation) --- ## Schema in a Nutshell `Schema` describes the shape of a Scala data type: its fields, their types and its cases. You derive it once per type, and every format block reads that description to encode and decode your data. This is the central idea of ZIO Blocks, and many other blocks build on it. ## The Problem It Solves In most Scala projects, each data format needs its own codec for each type: one for JSON, another for Avro, another for the database. The codecs repeat the same information, the shape of the type, and they drift apart when the type changes. With Schema, the shape lives in one place. A change to a case class updates every format at once, because every format is derived from the same schema. ## Derive a Schema We derive a schema by putting an implicit `Schema` in the companion object of the type. `Schema.derived` reads the fields at compile time: ```scala final case class Customer(name: String, email: String, vip: Boolean) object Customer { implicit val schema: Schema[Customer] = Schema.derived } ``` The same line works for sealed traits, nested case classes and collections; on Scala 3 you can also write `derives Schema` on the case class. ## Read and Write JSON JSON support is part of the Schema block itself. With the schema in scope, a value encodes with `toJsonString`, and a string decodes with `fromJson`: ```scala val alice = Customer("Alice", "alice@example.com", vip = true) // alice: Customer = Customer( // name = "Alice", // email = "alice@example.com", // vip = true // ) println(alice.toJsonString) // {"name":"Alice","email":"alice@example.com","vip":true} """{"name":"Bob","email":"bob@example.com","vip":false}""".fromJson[Customer] // res1: Either[SchemaError, Customer] = Right( // Customer(name = "Bob", email = "bob@example.com", vip = false) // ) ``` Decoding never throws for bad input. It returns an `Either`, and the message of the error names the field that failed: ```scala """{"name":"Carol","email":"carol@example.com","vip":"yes"}""".fromJson[Customer].left.map(_.getMessage) // res2: Either[String, Customer] = Left("illegal boolean at: .vip") ``` ## One Schema, Many Formats Other formats are separate blocks that read the same schema. For example, after adding `zio-blocks-schema-yaml` to the build, we derive a YAML codec from the schema we already have, with no new code in the `Customer` type: ```scala val yamlCodec = Customer.schema.derive(YamlFormat) ``` The codec encodes `alice` to YAML bytes: ```scala println(new String(yamlCodec.encode(alice), "UTF-8")) // name: Alice // email: alice@example.com // vip: true ``` Avro, BSON, CSV, MessagePack, Thrift, TOON and XML work the same way; [Built-in Formats and Codecs](../../reference/schema/built-in-codecs/index.md) lists them all. ## What Else a Schema Gives You A schema is a full description of the type, so it powers more than codecs: - [Validated types](../../guides/validated-newtypes.md) wrap a field in a rule with `Schema#transform`, so a value that breaks the rule fails to decode. - [Optics](../../reference/schema/optics.md) read and update deeply nested fields without hand-written `copy` chains. - [Patches](../../reference/schema/patch.md) compute the difference between two values and apply it elsewhere. - [Schema evolution](../../reference/schema/schema-evolution/index.md) converts data between an old and a new version of a type. - JSON Schema documents come from `Schema#toJsonSchema`, for APIs and payload validation. Other blocks use the schema too: the [SQL](../../reference/sql/index.md) block maps case classes to table rows with it, and the [Config](../../reference/config/index.md) block decodes configuration files into case classes with it. ## Learn More The [Your First Schema](../../guides/your-first-schema.md) tutorial walks through a complete round trip and reading decode errors, and the [Schema reference](../../reference/schema/index.md) documents every part of the block. The next concept in this section is [Scope](./scope.md). --- ## Scope in a Nutshell `Scope` manages resources: files, network connections, database pools and anything else that must be closed after use. You open resources inside a scope, and the scope closes all of them when it ends, in reverse order, even when the code inside fails. The compiler checks that no resource escapes the scope that owns it. ## The Problem It Solves Plain Scala closes resources with `try`/`finally`, one level of nesting per resource. With three resources the closing logic outgrows the real work, and it is easy to forget one `close` or to return a reader from a method after its file has been closed. A scope replaces that nesting with one region of code. Every resource opened in it is registered with the scope, and closing happens in one place, automatically. ## Open a Resource in a Scope A `Resource` is a recipe: it says how to acquire a value and how to release it, but does nothing until a scope allocates it. Here we describe a connection with `Resource.acquireRelease`, printing a message in both steps so we can see when they run: ```scala final class Connection(val name: String) { def query(sql: String): String = s"$name answered: $sql" } def connection(name: String): Resource[Connection] = Resource.acquireRelease { println(s"Opening $name") new Connection(name) } { conn => println(s"Closing ${conn.name}") } ``` `Scope.global.scoped` opens a scope for the block of code we pass it. Inside, `allocate` runs a recipe and registers the release step, and `$(value)(...)` is how we call methods on a scoped value: ```scala Scope.global.scoped { scope => val users = allocate(connection("users-db")) val orders = allocate(connection("orders-db")) println($(users)(_.query("SELECT name FROM users"))) println($(orders)(_.query("SELECT id FROM orders"))) } // Opening users-db // Opening orders-db // users-db answered: SELECT name FROM users // orders-db answered: SELECT id FROM orders // Closing orders-db // Closing users-db ``` Both connections close when the block ends, the last one opened first. Classes that implement `AutoCloseable` need no release step at all: `Resource(new FileInputStream(file))` closes the stream for you. ## Why the Compiler Can Check It Inside a scope, `allocate` returns a value of type `$[Connection]`, not a plain `Connection`. The `$` type is tied to the scope that owns the resource, so the compiler rejects a block that tries to return the resource. Plain data, such as the `String` that `query` returns, leaves the scope freely. In practice this turns the classic bug, handing out a reader whose file is about to close, into a compile error. Code that reaches a resource some other way after its scope has closed, for example from a callback that runs later, gets an `IllegalStateException` that says the scope is closed, instead of silently reading from a closed file. ## What Else Scope Gives You The block covers more than one flat region: - Child scopes nest inside a parent and close before it, for per-request or per-task resources. - `Resource.shared` lets several services use one resource, which closes when the last user is done. - [Wire](./wire-and-context.md) builds whole applications of services and their resources from constructors. ## Learn More The [Compile-Time Resource Safety with Scope](../../guides/compile-time-resource-safety-with-scope.md) tutorial goes through the block step by step, and the [Resource Management reference](../../reference/resource-management/index.md) documents `Scope`, `Resource` and the related types. The next concept in this section is [Wire and Context](./wire-and-context.md). --- ## Streams in a Nutshell A `Stream[E, A]` describes a sequence of `A` values that arrives piece by piece, and that may fail with an error of type `E`. You build a pipeline by transforming the stream, then run it to get a result. Because a stream pulls one element at a time, it processes files, sockets and query results that do not fit in memory. ## The Problem It Solves Reading a large file into a `List` works until the file is larger than the heap. Iterators avoid that, but they have no way to report a typed error, no way to close the file when processing stops early, and no clear point where the work happens. A stream fixes all three. Its error type is part of its type, it can own the resource it reads from, and nothing happens until you run it. ## Describe, Then Run We describe a stream of orders and a pipeline over it. These lines do no work yet; they only describe what should happen: ```scala final case class Order(id: Int, amountCents: Long, paid: Boolean) val orders: Stream[Nothing, Order] = Stream( Order(1, 2500L, paid = true), Order(2, 1200L, paid = false), Order(3, 4800L, paid = true), Order(4, 900L, paid = true) ) val paidAmounts: Stream[Nothing, Long] = orders.filter(_.paid).map(_.amountCents) ``` The type `Stream[Nothing, Order]` says that this stream cannot fail. Running it does the work and returns a plain `Either`, with the error on the left and the result on the right: ```scala paidAmounts.runCollect // res0: Either[Nothing, Chunk[Long]] = Right(IndexedSeq(2500L, 4800L, 900L)) paidAmounts.runFold(0L)((total, cents) => total + cents) // res1: Either[Nothing, Long] = Right(8200L) ``` The same `paidAmounts` value can run again, and each run starts from the beginning. ## Errors Are Part of the Type A step that can fail turns a `Stream[Nothing, A]` into a stream with a real error type. Here each text line becomes an order, or fails with a `BadLine` error: ```scala final case class BadLine(line: String) def parse(line: String): Stream[BadLine, Order] = line.split(",") match { case Array(id, cents, paid) if id.toIntOption.isDefined && cents.toLongOption.isDefined => Stream.succeed(Order(id.toInt, cents.toLong, paid == "paid")) case _ => Stream.fail(BadLine(line)) } val parsed: Stream[BadLine, Order] = Stream("1,2500,paid", "2,oops,unpaid", "3,4800,paid").flatMap(parse) ``` Running the stream stops at the first error and returns it as a value: ```scala parsed.runCollect // res2: Either[BadLine, Chunk[Order]] = Left(BadLine("2,oops,unpaid")) ``` `Stream#catchAll` replaces a failure with another stream. Applied to the parsing step of each line, it turns a bad line into an empty stream, so the pipeline skips that line and keeps going, and the error type becomes `Nothing` again: ```scala Stream("1,2500,paid", "2,oops,unpaid", "3,4800,paid") .flatMap(line => parse(line).catchAll(_ => Stream.empty)) .map(_.id) .runCollect // res3: Either[Nothing, Chunk[Int]] = Right(IndexedSeq(1, 3)) ``` Applied to the whole `parsed` stream instead, `catchAll` would take over at the first error and end the run there, without order 3. ## What Else Streams Give You The block is built for real input and output: - `Stream.fromAcquireRelease` opens a file or a connection when the stream starts and closes it when the stream ends, even if it fails or stops early. - A `Sink` describes how to consume a stream, such as counting, folding or writing to a file, and `Stream#run` connects the two. - Elements of type `Int`, `Long`, `Double` and the other primitives stay unboxed as they flow through a pipeline. - The run methods on this page block the calling thread and exist on the JVM. On Scala.js, or whenever a thread must not block, use the `Async` variants such as `Stream#runCollectAsync`. ## Learn More The [Your First Streaming Pipeline](../../guides/your-first-streaming-pipeline.md) tutorial builds a complete pipeline over a log file, and the [Streams reference](../../reference/streams/index.md) documents every operator. The next page shows how to [use ZIO Blocks with ZIO, cats-effect or plain Scala](../use-with-effect-systems.md). --- ## Wire and Context in a Nutshell An application is a graph of services: a web handler needs a user service, which needs a database, which needs its configuration. `Wire` builds that graph for you from ordinary constructors, checks it at compile time, and hands every resource in it to a [scope](./scope.md) so that it closes in the right order. `Context` is the small data type underneath: a set of values looked up by their type. ## The Problem It Solves Without a dependency injection tool, `main` creates every service by hand, in the right order, and passes each one to the constructors that need it. That code grows with every service, and the order of closing resources has to be kept in sync by hand. Runtime injection frameworks remove that code but report a missing service only when the application starts. Wire does the same job with a macro, so a broken graph is a compile error, and there is no framework and no annotations: your classes stay plain classes with constructor parameters. ## Build a Graph from Constructors We start with three plain classes. `Database` is a resource, so it implements `AutoCloseable`; `UserService` needs a `Database`; `Database` needs a `DbConfig`: ```scala final case class DbConfig(url: String) final class Database(config: DbConfig) extends AutoCloseable { println(s"Connecting to ${config.url}") def query(sql: String): String = s"rows for: $sql" def close(): Unit = println("Database closed") } final class UserService(db: Database) { def userName(id: Int): String = db.query(s"SELECT name FROM users WHERE id = $id") } ``` `Resource.from[UserService]` reads the constructors at compile time and builds the whole graph. We pass a `Wire` only for what it cannot build on its own, here the configuration value: ```scala def userService: Resource[UserService] = Resource.from[UserService]( Wire(DbConfig("jdbc:postgresql://localhost/app")) ) ``` Allocating the resource in a scope creates the database, then the service; when the scope ends, the database closes: ```scala Scope.global.scoped { scope => val service = allocate(userService) println($(service)(_.userName(42))) } // Connecting to jdbc:postgresql://localhost/app // rows for: SELECT name FROM users WHERE id = 42 // Database closed ``` Without the `DbConfig` wire, the macro would try to build `DbConfig` from its constructor, find that it needs a `String` it has no way to invent, and stop with a compile error that names the missing type and the chain of services that needed it. ## Shared and Unique Services Every service that the macro builds on its own is created once and shared by everything that needs it. To change that for one type, pass `Wire.unique[T]`, which creates a fresh instance for each service that depends on `T`; `Wire.shared[T]` states the default explicitly. Both wires are derived from the constructor of `T`, like the rest of the graph. ## Context: Values Looked Up by Type `Context` holds a few values of different types and returns each one by its type. The type of the context lists what it contains, here `Context[RequestId & UserId]` (written `RequestId with UserId` on Scala 2), so asking for a type it does not contain is a compile error: ```scala final case class RequestId(value: String) final case class UserId(value: Long) val ctx = Context(RequestId("req-17"), UserId(42L)) ``` Each lookup returns the value with its own type, here a `UserId`: ```scala ctx.get[UserId] // res1: UserId = UserId(42L) ``` Wire uses `Context` to pass each constructor its dependencies, and you can use it directly to carry request-level values, such as a request id, through your own code. ## Learn More The [Wire reference](../../reference/resource-management/wire.md) covers manual wires and sharing in detail, the [Context reference](../../reference/context.md) covers the full `Context` API, and [How to Fix Common Scope and Wire Compile Errors](../../guides/fix-scope-and-wire-compile-errors.md) explains each error the macro can report. The next concept in this section is [Streams](./streams.md). --- ## Your First Five Minutes with ZIO Blocks In five minutes, we write one small program that uses three blocks together. It reads temperature readings from a file in JSON Lines format (one JSON object per line), decodes each line into a case class, skips the line that is broken, and prints a summary as JSON. Each block does one job: - [Schema](./concepts/schema.md) turns JSON into case classes and back. - [Scope](./concepts/scope.md) opens the file and guarantees that it is closed. - [Chunk](../reference/chunk.md) holds the decoded readings and computes the summary. No block needs a runtime, an effect type or any setup: every call on this page is an ordinary method call. ## Add Two Dependencies The program needs two artifacts; Chunk comes along with Schema. On Scala 2.13 and Scala 3.3 to 3.8, add them to `build.sbt`: ```scala libraryDependencies ++= Seq( "dev.zio" %% "zio-blocks-schema" % "0.0.56", "dev.zio" %% "zio-blocks-scope" % "0.0.56" ) ``` On Scala 3.9 and newer, the artifact names start with `zio-blocks-next-` and need `-experimental`; [Installation](./installation.md) covers that case, scala-cli and Scala.js. ## Describe the Data with Schema We start with two case classes: `Reading` is one line of the file, and `Summary` is the result we print. Giving each one a derived `Schema` is all it takes to read and write it as JSON: ```scala final case class Reading(sensor: String, celsius: Double) object Reading { implicit val schema: Schema[Reading] = Schema.derived } final case class Summary(readings: Int, averageCelsius: Double, hottestSensor: String) object Summary { implicit val schema: Schema[Summary] = Schema.derived } ``` With the schema in scope, every `Reading` has a `toJsonString` method, and every `String` has a `fromJson` method that returns either an error or a value: ```scala println(Reading("kitchen", 21.5).toJsonString) // {"sensor":"kitchen","celsius":21.5} """{"sensor":"garage","celsius":12.0}""".fromJson[Reading] // res1: Either[SchemaError, Reading] = Right( // Reading(sensor = "garage", celsius = 12.0) // ) ``` ## Read the File with Scope Now we need a file to read. This block writes four readings to a temporary file; the third one is broken on purpose, because its temperature is a string: ```scala val path: Path = Files.createTempFile("readings", ".jsonl") Files.writeString( path, """{"sensor":"kitchen","celsius":21.5} |{"sensor":"garage","celsius":12.0} |{"sensor":"attic","celsius":"hot"} |{"sensor":"office","celsius":23.5} |""".stripMargin ) ``` A file reader must be closed after use, even when reading fails. `Scope.global.scoped` opens a region of code; `allocate` registers the reader with that region, and the region closes the reader when it ends. The `$(reader)(...)` call is how we use the reader inside the region: Scope checks at compile time that the reader itself never escapes it, while the list of lines does: ```scala def readLines(path: Path): List[String] = Scope.global.scoped { scope => val reader: $[BufferedReader] = allocate( Resource.acquireRelease { println("Opened the readings file") Files.newBufferedReader(path) } { reader => reader.close() println("Closed the readings file") } ) $(reader)(_.lines().iterator().asScala.toList) } ``` When we call it, the file opens, the lines come back, and the file is already closed by the time we hold the result: ```scala val lines = readLines(path) // Opened the readings file // Closed the readings file // lines: List[String] = List( // "{\"sensor\":\"kitchen\",\"celsius\":21.5}", // "{\"sensor\":\"garage\",\"celsius\":12.0}", // "{\"sensor\":\"attic\",\"celsius\":\"hot\"}", // "{\"sensor\":\"office\",\"celsius\":23.5}" // ) ``` ## Decode Each Line Each line decodes to an `Either`. Splitting the results with `List#partitionMap` gives us the errors on one side and the readings on the other: ```scala val (errors, parsed) = lines.map(_.fromJson[Reading]).partitionMap(identity) ``` Three lines decode, and the broken one becomes an error: ```scala parsed // res3: List[Reading] = List( // Reading(sensor = "kitchen", celsius = 21.5), // Reading(sensor = "garage", celsius = 12.0), // Reading(sensor = "office", celsius = 23.5) // ) errors.foreach(error => println(s"Skipped a line: ${error.getMessage}")) // Skipped a line: illegal number at: .celsius ``` The error message names the field that failed and the reason, so a bad line in a large file is easy to find. ## Summarize with Chunk `Chunk` is the sequence type that all blocks share. It works like a `Vector`, and it stores primitives such as `Double` without boxing them. We compute the summary from a `Chunk` of readings, then print it as JSON with the schema we derived for `Summary`: ```scala def summarize(readings: Chunk[Reading]): Summary = Summary( readings = readings.size, averageCelsius = readings.map(_.celsius).sum / readings.size, hottestSensor = readings.maxBy(_.celsius).sensor ) println(summarize(Chunk.fromIterable(parsed)).toJsonString) // {"readings":3,"averageCelsius":19.0,"hottestSensor":"office"} ``` ## The Whole Program Here is the complete program as one file. Run it from the `zio-blocks-examples` directory of the repository with `sbt "gettingStarted/runMain gettingstarted.FirstFiveMinutes"`: ```scala title="zio-blocks-examples/getting-started/src/main/scala/gettingstarted/FirstFiveMinutes.scala" package gettingstarted /** * Getting Started — Your First Five Minutes * * Reads temperature readings from a JSON Lines file, decodes them with Schema, * summarizes them in a Chunk, and prints the summary as JSON. Scope opens the * file and closes it as soon as the lines are read. * * Run with: sbt "gettingStarted/runMain gettingstarted.FirstFiveMinutes" */ final case class Reading(sensor: String, celsius: Double) object Reading { implicit val schema: Schema[Reading] = Schema.derived } final case class Summary(readings: Int, averageCelsius: Double, hottestSensor: String) object Summary { implicit val schema: Schema[Summary] = Schema.derived } object FirstFiveMinutes { def readLines(path: Path): List[String] = Scope.global.scoped { scope => val reader: $[BufferedReader] = allocate( Resource.acquireRelease { println("Opened the readings file") Files.newBufferedReader(path) } { reader => reader.close() println("Closed the readings file") } ) $(reader)(_.lines().iterator().asScala.toList) } def summarize(readings: Chunk[Reading]): Summary = Summary( readings = readings.size, averageCelsius = readings.map(_.celsius).sum / readings.size, hottestSensor = readings.maxBy(_.celsius).sensor ) def main(args: Array[String]): Unit = { val path = Files.createTempFile("readings", ".jsonl") Files.writeString( path, """{"sensor":"kitchen","celsius":21.5} |{"sensor":"garage","celsius":12.0} |{"sensor":"attic","celsius":"hot"} |{"sensor":"office","celsius":23.5} |""".stripMargin ) val lines = readLines(path) val (errors, parsed) = lines.map(_.fromJson[Reading]).partitionMap(identity) errors.foreach(error => println(s"Skipped a line: ${error.getMessage}")) val readings = Chunk.fromIterable(parsed) println(summarize(readings).toJsonString) Files.delete(path) } } ``` ## What You Just Used The program uses three blocks, and each one has a page in this section or in the reference: | Block | What it does here | Learn more | | ------ | --------------------------------------------------- | ------------------------------------------------ | | Schema | Decodes each JSON line and encodes the summary | [Schema in a Nutshell](./concepts/schema.md) | | Scope | Opens the file and closes it after reading | [Scope in a Nutshell](./concepts/scope.md) | | Chunk | Holds the readings and computes the summary | [Chunk reference](../reference/chunk.md) | Next, [How ZIO Blocks Thinks](./how-zio-blocks-thinks.md) explains why the blocks fit together this easily. --- ## How ZIO Blocks Thinks Every block in ZIO Blocks follows the same design. Once you know it, each new block feels familiar: you already know how to add it, how to call it and how it reports errors. This page explains that design in six ideas, then shows how the blocks build on each other. ## A Block Is a Library, Not a Framework You call a block; it never calls you. There is no application base class to extend, no runtime to start and no main loop to hand control to. A block is a set of types and methods, and your code decides when to use them. This is why ZIO Blocks fits into an existing project one block at a time. You can replace a JSON library with [Schema](./concepts/schema.md) in one module and leave the rest of the code base untouched. ## Plain Values Instead of an Effect Type Effect systems such as ZIO and cats-effect describe work as values that run later. ZIO Blocks does not require one: its methods do the work when you call them and return ordinary Scala values. Decoding JSON returns an `Either`, running a [stream](./concepts/streams.md) on the JVM returns an `Either` with the collected elements, and a database query returns its rows. Expected failures are values too. A malformed JSON document comes back as the `Left` side of an `Either`, with a message that says what went wrong and where, and a stream declares the type of its errors in its own type, `Stream[E, A]`. When you do want to run work without blocking a thread, the [Async](../reference/async.md) block provides a small asynchronous type, and blocks such as Streams offer `Async` variants of their run methods. If your application already has an effect system, [Use ZIO Blocks with ZIO, cats-effect or Plain Scala](./use-with-effect-systems.md) shows where these calls go. ## One Artifact per Block Each block is published as its own artifact under the `dev.zio` organization, and blocks depend on each other only where they share a real need. Adding `zio-blocks-scope` to a build does not pull in JSON codecs, and adding `zio-blocks-schema` does not pull in a streaming library. The blocks form layers. Small foundation blocks at the bottom hold the shared data types; three hub blocks in the middle describe data, own resources and move data; the application blocks at the top combine them. Each arrow means "builds on": ```text Application blocks sql config endpoint html telemetry │ │ │ │ │ ▼ ▼ ▼ ▼ │ Hub blocks schema ◄──────────── describes your data │ streams ◄─────────── moves data in pieces │ scope ◄───────────── owns resources │ │ │ ▼ ▼ Foundation blocks chunk maybe typeid context async combinators ``` For example, the SQL block uses Schema to map case classes to rows, Streams to read large results and Scope to close connections; the Config block decodes your configuration into case classes through Schema. ## Mistakes Become Compile Errors Many problems that other libraries report at run time are compile errors in ZIO Blocks, because macros check them while your code compiles: - **Codecs.** `Schema.derived` inspects your case class or sealed trait and builds its schema at compile time, so there is no reflection at run time and a type that cannot be described fails to compile. - **Resource lifetimes.** [Scope](./concepts/scope.md) tags every resource with the region that owns it, so returning an open file or connection from that region does not compile. - **Dependency graphs.** [Wire](./concepts/wire-and-context.md) reads your constructors and assembles the services your application needs; a dependency that it cannot build is a compile error that names the type. ## Fast Without Extra Work The blocks avoid work that slows down common Scala code. [Chunk](../reference/chunk.md) stores `Int`, `Long`, `Double` and the other primitives in primitive arrays instead of boxing each element, and Streams keeps primitive elements unboxed as they flow through a pipeline. You get this by using the default APIs; there is no separate fast mode to turn on. ## The Same Code on Every Platform Most blocks build for Scala 2.13 and Scala 3, on the JVM and on Scala.js, from the same source code. You can move a project from Scala 2.13 to Scala 3 without changing how you call a block. The few blocks that are narrower, such as SQL, which needs Scala 3, say so in the catalog on the [overview](../index.md). ## Putting the Ideas Together These ideas explain why the program in [Your First Five Minutes](./first-five-minutes.md) needs no setup: Schema, Scope and Chunk are plain libraries, they return plain values, they share the `Chunk` type, and the compiler checks that the file reader never leaves the scope that owns it. The next four pages introduce the blocks most applications start with, beginning with [Schema](./concepts/schema.md). --- ## What Is ZIO Blocks? ZIO Blocks is a family of small, independent Scala libraries. Each one solves one common problem: describing data and turning it into JSON, closing resources, wiring services together, streaming data, loading configuration, talking to a database, and more. You add the blocks you need to your build and call them from ordinary Scala code. This Getting Started section is a short tour. It teaches the core ideas, not every method: each page takes a few minutes, and links to the reference when you want the full details. ## Why ZIO Blocks Exists Most Scala libraries for these jobs come tied to an ecosystem. A JSON library expects you to use its effect type for streaming, a resource library expects a runtime, a database library expects a particular effect system. Picking one library often means adopting a whole stack. ZIO Blocks takes the opposite approach. A block needs no effect system and no runtime: its methods are plain Scala functions that return plain values. That is why the same block works in a ZIO application, a cats-effect application, an Akka or Ox application, or a plain `main` method. ## The Ideas Behind It Every block follows the same few principles: - **No lock-in.** No block depends on ZIO, cats-effect or any other effect system. - **Modular.** Each block is its own artifact, so your build contains only what you use. - **Cross-platform.** Most blocks build for Scala 2.13 and Scala 3, on the JVM and on Scala.js, from the same source. - **Checked at compile time.** Macros derive codecs, check resource lifetimes and build dependency graphs while your code compiles, so mistakes show up as compile errors. - **Fast.** Implementations avoid boxing and needless allocation on hot paths. [How ZIO Blocks Thinks](./how-zio-blocks-thinks.md) explains these ideas in more detail and shows how the blocks build on each other. ## Which Block Do I Need? Start from the problem you have. This table lists the most common problems and the block that solves each one; the [overview](../index.md) has the full catalog of every block: | You need to... | Use this block | Artifact | | ------------------------------------------------------ | --------------------------------------------------------------- | ------------------------------- | | Turn a case class into JSON (or Avro, YAML, CSV...) | [Schema](../reference/schema/index.md) | `zio-blocks-schema` | | Open files or connections and always close them | [Scope](../reference/resource-management/index.md) | `zio-blocks-scope` | | Build services and their dependencies | [Wire](../reference/resource-management/wire.md) | `zio-blocks-scope` | | Process data that does not fit in memory | [Streams](../reference/streams/index.md) | `zio-blocks-streams` | | Load typed configuration from files and environment | [Config](../reference/config/index.md) | `zio-blocks-config` | | Query a database with type-safe SQL | [SQL](../reference/sql/index.md) | `zio-blocks-sql` | | Describe HTTP endpoints as data, for OpenAPI and more | [Endpoint](../reference/endpoint/index.md) | `zio-blocks-endpoint` | | Render HTML safely | [HTML](../reference/html.md) | `zio-blocks-html` | | Record logs, traces and metrics | [Telemetry](../reference/telemetry/index.md) | `zio-blocks-telemetry` | | Store sequences of primitives without boxing | [Chunk](../reference/chunk.md) | `zio-blocks-chunk` | | Run asynchronous code in direct style | [Async](../reference/async.md) | `zio-blocks-async` | ## How to Read This Section The pages build on each other, so read them in order: 1. [Installation](./installation.md) adds a block to your sbt or scala-cli build. 2. [Your First Five Minutes](./first-five-minutes.md) runs one small program that uses three blocks together. 3. [How ZIO Blocks Thinks](./how-zio-blocks-thinks.md) explains the design that all blocks share. 4. The core concept pages introduce the four blocks most applications start with: [Schema](./concepts/schema.md), [Scope](./concepts/scope.md), [Wire and Context](./concepts/wire-and-context.md) and [Streams](./concepts/streams.md). 5. [Use ZIO Blocks with ZIO, cats-effect or Plain Scala](./use-with-effect-systems.md) shows where ZIO Blocks calls go in an application that already has an effect system. 6. [Where Next](./where-next.md) points you to the tutorials, how-to guides and reference. If you are only evaluating the library, the first three pages are enough to decide whether it fits your project. --- ## How to Add ZIO Blocks to Your Build This guide gets ZIO Blocks onto your classpath. You'll pick the modules you need, write the dependency line for your Scala version, and check that it compiles. Each section is a copy-paste recipe. ## The Artifact Name Depends on Your Scala Version ZIO Blocks publishes every module under two names. The artifacts built with Scala 2.13 and Scala 3.3 LTS are called `zio-blocks-`. The artifacts built with the latest Scala 3 release are called `zio-blocks-next-`: | Your Scala version | Artifact name | Built with | |--------------------|--------------------------------|------------| | 2.13.x | `zio-blocks-` | 2.13.18 | | 3.3.x to 3.8.x | `zio-blocks-` | 3.3.7 LTS | | 3.9.x and newer | `zio-blocks-next-` | 3.9.0 | A Scala 3.9 build can still use `zio-blocks-`, but `zio-blocks-next-` is the build made for it. The reverse does not work: a Scala 3.3 to 3.8 compiler cannot read code compiled by Scala 3.9, so `zio-blocks-next-` fails there with a "Forward incompatible TASTy file" error. ## Prerequisites - **sbt 1.x** or **scala-cli**. - **JDK 11+** for Scala 2.13 and Scala 3.3 to 3.8; **JDK 17+** for the `zio-blocks-next-*` artifacts. - **JDK 21+** on the JVM for `streams`, `telemetry`, and `telemetry-otel`, and for every module built on `streams`: `http-model`, `http-model-schema`, `endpoint`, `datastar`, `http-htmx`, `sql`, `sql-zio`, `data-migration`, and `projection`. - For Scala.js, **Scala.js 1.22.0+** (the `sbt-scalajs` plugin in `project/plugins.sbt`). Older linkers reject the artifacts with `IRVersionNotSupportedException`. ## Pick Your Modules Every module lives under the `dev.zio` organization. Replace `` in the artifact name with a value from the first column: | `` | What you get | Platforms | Scala | |----------------------|---------------------------------------------------------------------|-----------|------------| | `schema` | `Schema` derivation, optics, validation, and the built-in JSON codec | JVM · JS | 2.13 · 3.x | | `schema-avro` | Apache Avro codec | JVM | 2.13 · 3.x | | `schema-bson` | MongoDB BSON codec | JVM | 2.13 · 3.x | | `schema-csv` | CSV codec | JVM · JS | 2.13 · 3.x | | `schema-messagepack` | MessagePack codec | JVM · JS | 2.13 · 3.x | | `schema-thrift` | Apache Thrift codec | JVM | 2.13 · 3.x | | `schema-toon` | TOON codec | JVM · JS | 2.13 · 3.x | | `schema-xml` | XML codec | JVM · JS | 2.13 · 3.x | | `schema-yaml` | YAML codec | JVM · JS | 2.13 · 3.x | | `typeid` | Compile-time type identity | JVM · JS | 2.13 · 3.x | | `chunk` | Immutable indexed sequences without boxing | JVM · JS | 2.13 · 3.x | | `maybe` | Low-allocation optional values | JVM · JS | 2.13 · 3.x | | `combinators` | Tuple, `Either`, and union composition | JVM · JS | 2.13 · 3.x | | `context` | Type-indexed heterogeneous collections | JVM · JS | 2.13 · 3.x | | `scope` | Compile-time safe resource management and `Wire` | JVM · JS | 2.13 · 3.x | | `async` | Asynchronous effect type with direct-style `await` | JVM · JS | 2.13 · 3.x | | `streams` | Pull-based streams | JVM · JS | 2.13 · 3.x | | `mux` | Stream multiplexer for HTTP/2, QUIC, and WebSocket-style protocols | JVM · JS | 2.13 · 3.x | | `ringbuffer` | Lock-free bounded ring buffers | JVM · JS | 2.13 · 3.x | | `config` | Typed configuration loading | JVM · JS | 2.13 · 3.x | | `config-yaml` | YAML source for `config` | JVM · JS | 2.13 · 3.x | | `config-json` | JSON source for `config` | JVM · JS | 2.13 · 3.x | | `config-hocon` | HOCON source for `config` | JVM · JS | 2.13 · 3.x | | `mediatype` | IANA media types | JVM · JS | 2.13 · 3.x | | `http-model` | HTTP data model: URLs, headers, requests, responses | JVM · JS | 2.13 · 3.x | | `http-model-schema` | Schema-based typed access to the HTTP model | JVM · JS | 2.13 · 3.x | | `endpoint` | Typed HTTP endpoint descriptions | JVM · JS | 2.13 · 3.x | | `openapi` | OpenAPI 3.1 generation | JVM · JS | 2.13 · 3.x | | `html` | Type-safe HTML templates | JVM · JS | 2.13 · 3.x | | `datastar` | Datastar attributes, signals, and SSE events | JVM · JS | 3.x | | `http-htmx` | Typed HTMX attributes | JVM · JS | 3.x | | `jwt` | JWT signing and verification | JVM · JS | 2.13 · 3.x | | `markdown` | GitHub Flavored Markdown parsing and rendering | JVM · JS | 2.13 · 3.x | | `telemetry` | Tracing, logging, and metrics | JVM · JS | 2.13 · 3.x | | `telemetry-otel` | OTLP exporters for `telemetry` | JVM | 2.13 · 3.x | | `sql` | Schema-driven SQL and a JDBC transactor | JVM · JS | 3.x | | `sql-zio` | ZIO integration for `sql` | JVM | 3.x | | `data-migration` | Typed database schema migrations | JVM · JS | 3.x | | `projection` | Event-sourced projections | JVM | 3.x | | `codegen` | Scala code generation IR and emitter | JVM | 2.13 · 3.x | | `smithy` | Smithy IDL parser and AST | JVM | 2.13 · 3.x | Modules pull in the ZIO Blocks modules they build on, so you only list the ones you use directly. ## Add a Dependency With sbt On Scala 2.13 or Scala 3.3 to 3.8, use the `zio-blocks-` prefix: ```scala libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.56" ``` On Scala 3.9 or newer, use the `zio-blocks-next-` prefix and enable experimental features. The `zio-blocks-next-*` artifacts are compiled with `-experimental`, so code that calls them must be too. Without the flag, the compiler reports that a definition "is marked @experimental": ```scala scalaVersion := "3.9.0" libraryDependencies += "dev.zio" %% "zio-blocks-next-schema" % "0.0.56" scalacOptions += "-experimental" ``` For Scala.js, write `%%%` instead of `%%`: ```scala libraryDependencies += "dev.zio" %%% "zio-blocks-schema" % "0.0.56" ``` ## Cross-Build for Several Scala Versions When one build compiles for several Scala versions, let sbt pick the prefix and the `-experimental` flag per version. Put this in `build.sbt`: ```scala crossScalaVersions := Seq("2.13.18", "3.3.7", "3.9.0") scalaVersion := crossScalaVersions.value.head val zioBlocksVersion = "0.0.56" // Scala 3.9+ uses the zio-blocks-next-* artifacts; older versions use zio-blocks-*. def zioBlocks(module: String) = Def.setting { val prefix = CrossVersion.partialVersion(scalaVersion.value) match { case Some((3, minor)) if minor >= 9 => "zio-blocks-next" case _ => "zio-blocks" } "dev.zio" %% s"$prefix-$module" % zioBlocksVersion } libraryDependencies ++= Seq( zioBlocks("schema").value, zioBlocks("streams").value ) // The zio-blocks-next-* artifacts require -experimental. scalacOptions ++= { CrossVersion.partialVersion(scalaVersion.value) match { case Some((3, minor)) if minor >= 9 => Seq("-experimental") case _ => Seq.empty } } ``` In a Scala.js or `crossProject` build, change `%%` to `%%%` inside `zioBlocks`. ## Add a Dependency With scala-cli :::warning scala-cli may report that a module "is outdated, update to 0.017". Ignore the hint: `0.017` is a mistyped February release that Maven ranks above the current versions, and following it downgrades you. ::: On Scala 2.13 or Scala 3.3 to 3.8: ```scala //> using scala 3.3.7 //> using dep dev.zio::zio-blocks-schema:0.0.56 ``` On Scala 3.9 or newer: ```scala //> using scala 3.9.0 //> using dep dev.zio::zio-blocks-next-schema:0.0.56 //> using option -experimental ``` For Scala.js, add the platform directive and put `::` before the version as well, the scala-cli equivalent of `%%%`: ```scala //> using platform scala-js //> using dep dev.zio::zio-blocks-schema::0.0.56 ``` ## Check That It Compiles Paste this into your project and run it. If it prints JSON, the dependency is on the classpath: ```scala case class Book(title: String, author: String) object Book { implicit val schema: Schema[Book] = Schema.derived } ``` Then encode a book to JSON: ```scala Book("Zionomicon", "John A. De Goes, Adam Fraser and Milad Khajavi").toJsonString // res0: String = "{\"title\":\"Zionomicon\",\"author\":\"John A. De Goes, Adam Fraser and Milad Khajavi\"}" ``` ## Things to Know - **Scala 3 only.** `sql`, `sql-zio`, `data-migration`, `projection`, `datastar`, and `http-htmx` have no Scala 2.13 artifacts. - **JVM only.** `schema-avro`, `schema-bson`, `schema-thrift`, `codegen`, `smithy`, `telemetry-otel`, `sql-zio`, and `projection` have no Scala.js artifacts. The `sql` module's JDBC classes are also JVM-only. - **Third-party dependencies.** Most modules depend only on the Scala standard library. The exceptions are: | Module | Pulls in | |-------------------------------|----------------------------------------------------------------| | `schema-avro` | `org.apache.avro:avro` | | `schema-bson` | `org.mongodb:bson` | | `schema-thrift` | `org.apache.thrift:libthrift`, `jakarta.annotation-api` | | `schema` on Scala.js | `scala-java-time`, `scala-java-time-tzdb` | | `async` on Scala 3 | `dotty-cps-async`; also reaches you through `streams` and every module built on it | | `sql-zio` | `zio` | | `projection` | `zio`, `zio-streams` | - **Scala.js and `async` on Scala 3.9.** The `zio-blocks-next-async` artifact uses native JavaScript `async`/`await`. If the Scala.js linker reports that a feature requires ES2017, set the target in your Scala.js project: ```scala scalaJSLinkerConfig ~= (_.withESFeatures(_.withESVersion(org.scalajs.linker.interface.ESVersion.ES2017))) ``` ## Going Further - [Your First Five Minutes](./first-five-minutes.md) is the next page of Getting Started: one small program that uses three blocks together. - [Your First Schema](../guides/your-first-schema.md) is the next step once `zio-blocks-schema` is on the classpath. - The [reference](../reference/index.md) has a page for every module in the table above. - Each `*-examples/` directory in the [repository](https://github.com/zio/zio-blocks) is a standalone sbt build that depends on the published artifacts; `schema-examples/build.sbt` is a working copy-paste template. --- ## Use ZIO Blocks with ZIO, cats-effect or Plain Scala ZIO Blocks needs no effect system, so the question for most projects is not whether a block fits, but where its calls go. This page answers it in one table for each kind of call. [How to Use ZIO Blocks in a ZIO or cats-effect Application](../guides/use-zio-blocks-in-zio-or-cats-effect.md) shows every case with complete, runnable code. ## In Plain Scala In a plain Scala program, there is nothing to arrange: call a block where you need it. Decoding returns an `Either`, a scope closes its resources when its block ends, and a stream runs when you call one of its run methods. Every example in this Getting Started section is plain Scala. The one choice you still make is about threads. Some calls block the calling thread until they finish, such as a database query or `Stream#runCollect` on the JVM. In a command-line tool or a batch job that is fine; in a server, run them on a thread pool meant for blocking work, or use the `Async` variants that the blocks provide. ## In a ZIO or cats-effect Application In an application that already runs on ZIO or cats-effect, each kind of ZIO Blocks call has one place where it belongs. Pure calls stay as they are, blocking calls move to the effect system's blocking pool, and resources and streams are wrapped once so that interruption and cleanup work across the boundary: | Kind of call | Example | In ZIO | In cats-effect | | -------------------------------------------------- | ----------------------------------------------- | ------------------------------------------ | ------------------------------------------ | | Pure and fast | `toJsonString`, `fromJson`, `Chunk` operations | Call it directly, or `ZIO.succeed` | Call it directly, or `IO.pure` | | Returns an `Either` | `fromJson`, a decode with any format codec | `ZIO.fromEither` | `IO.fromEither` | | Blocks a thread | SQL queries, `Stream#runCollect` on the JVM | `ZIO.attemptBlocking` | `IO.blocking` | | Owns a resource | A `Resource` from Scope | Wrap it in `ZIO.acquireRelease` | Wrap it in `Resource.make` | | Runs asynchronously | An `Async` value | Bridge it with `ZIO.asyncInterrupt` | Bridge it with `IO.async` | | Produces a stream | A ZIO Blocks `Stream` | Turn it into a `ZStream` | Turn it into an fs2 `Stream` | ZIO has library help for one case: the `sql-zio` module returns ZIO effects that already run on the blocking pool and provides the database as a `ZLayer` ([How to Use the sql Module from a ZIO Application](../guides/sql-from-zio.md)). Every other bridge is a few lines of your own code, and the full guide gives you those lines for both effect systems. ## Why It Works This Way Because a block does its work when you call it, it cannot know whether the calling thread may block, or how your effect system cancels work. Keeping that decision in your code means the same block works in ZIO, cats-effect, Kyo, Ox, Akka or a plain `main` method, and you never adapt your application to a runtime that ZIO Blocks would otherwise bring along. ## Learn More [How to Use ZIO Blocks in a ZIO or cats-effect Application](../guides/use-zio-blocks-in-zio-or-cats-effect.md) builds a small orders service that covers every row of the table, with cancellation and cleanup tested in both effect systems. If you are moving an existing code base, the [migration guides](../guides/index.md) map common Scala libraries to their ZIO Blocks counterparts. Finally, [Where Next](./where-next.md) suggests what to read after this section. --- ## Where Next You now know what ZIO Blocks is, how its blocks are designed, and the four blocks most applications start with. The rest of the documentation has four kinds of pages, each for a different moment. ## How the Documentation Is Organized Each kind of page answers a different question: | Kind of page | Read it when you want to... | | --------------------------------------------- | ---------------------------------------------------- | | [Tutorials](/guides/tutorials) | Learn a block step by step, from nothing | | [How-to guides](/guides/how-to) | Solve one specific task in a project | | [Migration guides](/guides/migration) | Move existing code from another Scala library | | [Reference](../reference/index.md) | Look up every type and method of a block | ## Pick a Tutorial by Goal The tutorials are the natural next step. Each one builds a small program from scratch, and they are listed here by what you want to do: | You want to... | Start with | | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | Read and write JSON | [Your First Schema: Round-Trip a Type Through JSON](../guides/your-first-schema.md) | | Change a data type without breaking old data | [Evolve Your Data](../guides/evolve-your-data.md) | | Close files and connections safely | [Compile-Time Resource Safety with Scope](../guides/compile-time-resource-safety-with-scope.md) | | Load configuration into case classes | [Build a Typed Configuration Loader](../guides/typed-config-loader.md) | | Run asynchronous code without blocking threads | [Getting Started with Async](../guides/async-getting-started.md) | | Process a large file piece by piece | [Your First Streaming Pipeline](../guides/your-first-streaming-pipeline.md) | | Store case classes in a database | [Build Your First Database Repository on SQLite](../guides/your-first-sqlite-repository.md) | | Render a web page | [Render Your First Server-Side HTML Page](../guides/your-first-html-page.md) | | Add logs, traces and metrics | [Instrument a Program with Logs, Traces and Metrics](../guides/instrument-a-program-with-logs-traces-and-metrics.md) | | Combine several blocks into one small service | [Build a Small JSON Backend from Blocks](../guides/small-json-backend-from-blocks.md) | ## Get Help If something is unclear or does not work as described, ask on the [ZIO Discord](https://discord.gg/2ccFBr4) or open an issue in the [GitHub repository](https://github.com/zio/zio-blocks). The [API documentation](pathname:///api/) lists every public type of every block. --- ## Add Interactivity to a Page with HTMX Welcome! This tutorial is for Scala developers who can already build an HTML page with the ZIO Blocks HTML module and now want parts of that page to update without a reload. You don't need any JavaScript, and you don't need prior experience with htmx. If you haven't built a page with the HTML module yet, start with [Render Your First Server-Side HTML Page](./your-first-html-page.md); this tutorial picks up where it ends. ## 1. Introduction By the end of this tutorial, you will understand: - How htmx turns an ordinary HTML element into one that asks the server for an HTML fragment and swaps it into the page - How the typed `hx-*` attributes (`hxGet`, `hxTarget`, `hxTrigger`, `hxInclude`, `hxPushUrl`) attach to elements built with the HTML DSL - How to debounce a search box with `HxTrigger` so the server is asked only when the reader pauses and the text has changed - How one URL answers with a full page or a fragment, depending on the typed `HxRequest` header We learn these concepts by turning a static list of Scala books into a live search page, one step at a time: 1. Serve a page that loads htmx 2. Make your first htmx request 3. Search as the reader types 4. Filter by category 5. Answer one URL with a full page or a fragment At the end, we run the finished page and try it in a browser. We recommend reading from top to bottom — each section builds on the previous one. The HTMX module is for Scala 3 only. To follow along, add it to your `build.sbt`: ```scala // Scala 3.3 to 3.8 libraryDependencies += "dev.zio" %% "zio-blocks-http-htmx" % "0.0.56" // Scala 3.9 and newer (also needs scalacOptions += "-experimental") libraryDependencies += "dev.zio" %% "zio-blocks-next-http-htmx" % "0.0.56" ``` The HTMX module depends on the HTML module and the http-model module, so `Dom`, `Request` and `Response` come with it. The http-model module is compiled for Java 21, so the programs in this tutorial need JDK 21 or newer to run. ## 2. Background: The Server Sends HTML, htmx Swaps It In a classic server-rendered site, every click loads a whole new page. A JavaScript single-page app avoids that, but then the browser has to fetch JSON, turn it into HTML itself and keep a second copy of the page's logic in JavaScript. htmx takes a third path. It is a small JavaScript library that reads extra attributes on ordinary HTML elements. An attribute such as `hx-get="/search"` tells htmx to send a `GET /search` request when something happens to that element, and `hx-target="#results"` tells it where to put the answer. The server answers with a piece of HTML (a fragment), and htmx swaps it into the page. All of the HTML is still built on the server, by the same Scala code that builds the full page: ```text Browser Server ─────── ────── │ the reader types "zio" │ ├──── GET /search?q=zio ───────────────► findBooks("zio") │ HX-Request: true │ │ ▼ ◄──── Zionomicon … ──── resultList(...).render │ ▼ htmx replaces the content of ``` The ZIO Blocks HTMX module gives each `hx-*` attribute a typed key, such as `hxGet` and `hxTarget`, for the HTML DSL. Each key accepts only values that make sense for it, so a typo in a trigger or a target is a compile error instead of a page that silently does nothing. The module writes the attributes; the htmx library in the browser reads them. The attributes follow htmx 2, so this tutorial loads htmx 2.0.11. htmx 4 doesn't read several attributes the module offers, such as `hx-params` and `hx-disinherit`. ## 3. Serve a Page That Loads htmx Let's start with the data: a `Book` with a title, an author and a category, and eight real Scala books. `findBooks` keeps the books whose title or author contains the search text and whose category matches, and `resultList` turns the matches into a ``, or into a short message when nothing matches: ```scala final case class Book(title: String, author: String, category: String) val books: List[Book] = List( Book("Zionomicon", "John A. De Goes, Adam Fraser and Milad Khajavi", "Effects"), Book("Essential Effects", "Adam Rosien", "Effects"), Book("Practical FP in Scala", "Gabriel Volpe", "Effects"), Book("Functional Programming in Scala", "Paul Chiusano and Rúnar Bjarnason", "Functional Programming"), Book("Scala with Cats", "Noel Welsh and Dave Gurnell", "Functional Programming"), Book("Programming in Scala", "Martin Odersky, Lex Spoon and Bill Venners", "Language"), Book("Scala for the Impatient", "Cay S. Horstmann", "Language"), Book("Hands-on Scala Programming", "Li Haoyi", "Language") ) val categories: List[String] = List("Effects", "Functional Programming", "Language") def findBooks(query: String, category: String): List[Book] = { val needle = query.trim.toLowerCase books.filter { book => val matchesText = needle.isEmpty || book.title.toLowerCase.contains(needle) || book.author.toLowerCase.contains(needle) val matchesCategory = category.isEmpty || book.category == category matchesText && matchesCategory } } def resultList(found: List[Book]): Dom.Element = if (found.isEmpty) p("No books match your search.") else ul(found.map(book => li(strong(book.title), s" by ${book.author}"))) ``` The code above sets up everything the later sections reuse: - `import zio.blocks.html._` brings in the HTML DSL, and `import zio.http._` brings in the http-model types `Request`, `Response`, `Body` and friends. - `findBooks` ignores case and treats an empty query or an empty category as "match everything". Its name isn't `search` because the HTML DSL already has a `search` value for the `` tag. - `resultList` is the piece of HTML that changes as the reader searches. We render it inside the full page now, and later we send it on its own as a fragment. Now let's build the page. It looks like the page from the HTML tutorial, with one addition: a ` // // // Scala Bookshelf // // // // Zionomicon // by John A. De Goes, Adam Fraser and Milad Khajavi // // // Essential Effects // by Adam Rosien // // // Practical FP in Scala // by Gabriel Volpe // // // Functional Programming in Scala // by Paul Chiusano and Rúnar Bjarnason // // // Scala with Cats // by Noel Welsh and Dave Gurnell // // // Programming in Scala // by Martin Odersky, Lex Spoon and Bill Venners // // // Scala for the Impatient // by Cay S. Horstmann // // // Hands-on Scala Programming // by Li Haoyi // // // // // ``` The page is still static: loading htmx changes nothing until an element carries an `hx-*` attribute. To serve the page, we write the application as a plain function from `Request` to `Response`. The guide [How to Serve http-model Requests with the JDK HTTP Server](./serve-http-model-requests.md) explains this shape and provides a small adapter, `JdkHttp`, that runs such a function on the HTTP server built into the JDK. The companion examples use that adapter unchanged: ```scala def htmlResponse(html: String): Response = Response.ok(Body.fromArray(html.getBytes("UTF-8"), ContentType.`text/html`)) def notFound(request: Request): Response = Response.text(Status.NotFound, s"No route for ${request.method.name} ${request.path.encode}") def staticApp(request: Request): Response = (request.method, request.path.segments.toList) match { case (Method.GET, Nil) => htmlResponse(doctype.render + staticPage.render) case _ => notFound(request) } ``` The code above: - `htmlResponse` wraps rendered HTML in a `200 OK` response with `Content-Type: text/html`. `Body.fromString` would label the body `text/plain`, so we build it from bytes with an explicit content type. - `notFound` answers any other request with a `404` and a short message naming the method and path. - `staticApp` matches on the method and the path segments. `GET /` returns the page with a `` line in front; every other request goes to `notFound`. Because the application is just a function, we can call it without starting a server. This small helper builds a `GET` request for a target, passes it to an application and prints the status and the body. We use it in every section to see exactly what htmx would receive: ```scala def call(app: Request => Response, target: String, headers: Header*): Unit = { val url = URL.parse(target).fold(error => throw new IllegalArgumentException(error), identity) val request = headers.foldLeft(Request.get(url))((request, header) => request.addHeader(header)) val response = app(request) println(s"GET $target -> ${response.status.code}") println(response.body.asString()) } ``` The code above parses the target into a `URL`, builds the request with `Request.get` and adds any typed headers with `Request#addHeader`. The body is printed with `Body#asString`. ## 4. Make Your First htmx Request Let's make the page fetch something. A button gets two attributes: `hxGet` names the URL htmx requests when the button is clicked, and `hxTarget` names the element that receives the answer. The server needs a new route, `GET /books`, that answers with the result list only, not a whole page: ```scala val showAllButton: Dom.Element = button(hxGet := "/books", hxTarget := HxTarget.css("#results"))("Show all books") def buttonPage: Dom.Element = html(lang := "en")( head(meta(charset := "utf-8"), title("Scala Bookshelf"), htmxScript), body( h1("Scala Bookshelf"), showAllButton, div(id := "results", p("Click the button to load the books.")) ) ) def buttonApp(request: Request): Response = (request.method, request.path.segments.toList) match { case (Method.GET, Nil) => htmlResponse(doctype.render + buttonPage.render) case (Method.GET, List("books")) => htmlResponse(resultList(books).render) case _ => notFound(request) } ``` The code above: - `import zio.http.htmx._` brings in every typed attribute key, such as `hxGet` and `hxTarget`, and the value types they accept, such as `HxTarget`. - `hxGet := "/books"` becomes `hx-get="/books"`. A button triggers its request on a click, which is htmx's default for buttons. - `hxTarget := HxTarget.css("#results")` becomes `hx-target="#results"`. `hxTarget` only accepts an `HxTarget`, so a plain string such as `"#results"` doesn't compile. `HxTarget` also has `closest`, `find`, `next` and `previous` for targets relative to the element. - The `/books` route renders `resultList(books)` without ``, `` or ``. That is the fragment. Let's look at the button the page now contains: ```scala println(showAllButton.render) // ``` When the reader clicks it, htmx sends `GET /books` and puts the answer inside ``, replacing the "Click the button" paragraph. Let's send that same request ourselves: ```scala call(buttonApp, "/books") // GET /books -> 200 // Zionomicon by John A. De Goes, Adam Fraser and Milad KhajaviEssential Effects by Adam RosienPractical FP in Scala by Gabriel VolpeFunctional Programming in Scala by Paul Chiusano and Rúnar BjarnasonScala with Cats by Noel Welsh and Dave GurnellProgramming in Scala by Martin Odersky, Lex Spoon and Bill VennersScala for the Impatient by Cay S. HorstmannHands-on Scala Programming by Li Haoyi ``` This is the whole idea of htmx: the server answers with plain HTML, and the attributes decide when to ask and where the answer goes. By default htmx replaces the target's content (`innerHTML`); the `hxSwap` attribute takes an `HxSwap` value to choose another strategy, such as replacing the target itself with `HxSwap.OuterHTML`. ## 5. Search as the Reader Types A button is a good start, but readers expect results to update as they type. A text input can carry the same `hxGet` and `hxTarget` attributes, and htmx sends the input's value along with the request, using the input's `name` as the query parameter. What we still need to choose is when the request fires. That is the job of `hxTrigger`. If htmx sent a request on every key press, typing "scala" would send five requests, one per letter. A common fix is debouncing: wait until the reader pauses, then send one request. `HxTrigger` builds the trigger from the event name plus modifiers: ```scala val debounced: HxTrigger = HxTrigger("keyup").changed.delay(300.millis) ``` The code above builds the trigger value in three parts: - `HxTrigger("keyup")` fires on the `keyup` event, after each key press. `HxTrigger` has ready-made values for common events, such as `HxTrigger.click`, `HxTrigger.change` and `HxTrigger.input`, and takes any other event name as a string. - `HxTrigger#changed` skips the request when the value hasn't changed since the last one, so arrow keys and Shift don't trigger a search. - `HxTrigger#delay` with `300.millis` waits 300 milliseconds after the last `keyup` before sending. Each new key press restarts the wait, so a reader who types "scala" without pausing causes one request, not five. To see the `hx-trigger` value this produces, we print `HxTrigger#render`: ```scala println(debounced.render) // keyup changed delay:300ms ``` Notice that the modifiers render in the order we call them, and that the duration is written as `300ms`. Let's put the trigger on a search box and add a `/search` route that reads the `q` parameter: ```scala val liveSearchBox: Dom.Element.Void = input( `type` := "search", name := "q", placeholder := "Search by title or author", hxGet := "/search", hxTrigger := debounced, hxTarget := HxTarget.css("#results") ) def liveSearchApp(request: Request): Response = (request.method, request.path.segments.toList) match { case (Method.GET, List("search")) => val query = request.queryParams.getFirst("q").getOrElse("") htmlResponse(resultList(findBooks(query, "")).render) case _ => notFound(request) } ``` The code above: - `name := "q"` is what htmx uses as the parameter name, so typing "zio" sends `GET /search?q=zio`. - `input(...)` returns a `Dom.Element.Void`: `` is a void element, which takes attributes but never children. - `request.queryParams.getFirst("q")` reads the first value of the `q` parameter, or `None` when the request has none. An empty search shows every book. Let's check the rendered search box and send two searches: ```scala println(liveSearchBox.render) // call(liveSearchApp, "/search?q=zio") // GET /search?q=zio -> 200 // Zionomicon by John A. De Goes, Adam Fraser and Milad Khajavi call(liveSearchApp, "/search?q=haskell") // GET /search?q=haskell -> 200 // No books match your search. ``` The search for "zio" finds Zionomicon, and the search for "haskell" returns the "no match" paragraph. That paragraph is a fragment too, so the reader sees it right where the list was. :::tip htmx encodes parameter values with JavaScript's `encodeURIComponent`, so a space arrives as `%20` and `QueryParams` decodes it correctly. A plain HTML form submitted with `GET` encodes a space as `+` instead, which `QueryParams` keeps as a plus sign. If your page also has such a form, read [Values with Spaces and Plus Signs](./validate-query-parameters-and-headers.md#values-with-spaces-and-plus-signs). ::: ## 6. Filter by Category Let's add a drop-down list of categories next to the search box. A ` // // ``` Notice that the controls sit in a ``, not a `
`. Pressing Enter inside a form submits it the old-fashioned way and reloads the page, which we don't want here. Now let's send the two requests the browser would send, first after choosing "Effects" with an empty search box, then after typing "scala" with "Language" selected: ```scala call(filterApp, "/search?q=&category=Effects") // GET /search?q=&category=Effects -> 200 // Zionomicon by John A. De Goes, Adam Fraser and Milad KhajaviEssential Effects by Adam RosienPractical FP in Scala by Gabriel Volpe call(filterApp, "/search?q=scala&category=Language") // GET /search?q=scala&category=Language -> 200 // Programming in Scala by Martin Odersky, Lex Spoon and Bill VennersScala for the Impatient by Cay S. HorstmannHands-on Scala Programming by Li Haoyi ``` The first request lists the three books about effects. The second finds three of the six books with "scala" in their title, the ones in the "Language" category. ## 7. One URL, a Full Page or a Fragment The page works, but the address bar never changes. A reader can't bookmark a search or share it, and the browser's Back button leaves the page instead of going back to the previous search. The `hxPushUrl` attribute fixes that: after each request, htmx pushes the request's URL, such as `/search?q=zio&category=Effects`, into the address bar and the browser history. That raises a new question. When the reader reloads the page or opens a bookmark, the browser itself requests `/search?q=zio&category=Effects`, and the `/search` route answers with a bare ``. The browser would show an unstyled list with no heading and no search box. The route must answer with a full page when the browser asks and with a fragment when htmx asks. The server can tell them apart: htmx adds the header `HX-Request: true` to every request it sends. The HTMX module reads it as a typed header, `HxRequest`, from the `zio.http.htmx.headers` package: ```scala def wantsFragment(request: Request): Boolean = request.header(HxRequest).exists(_.enabled) && !request.header(HxHistoryRestoreRequest).exists(_.enabled) ``` The code above reads the header in two steps. `request.header(HxRequest)` parses the `HX-Request` header into an `HxRequest` value, or returns `None` when the header is missing, and `exists(_.enabled)` is `true` only for `HX-Request: true`. `HxHistoryRestoreRequest` handles one special case: when the reader goes back to a search that htmx no longer has in its history cache, htmx 2 requests that URL with `HX-Request: true` and `HX-History-Restore-Request: true`, and it puts the answer in place of the whole ``. Answering with a fragment there would replace the heading and the search box with a bare list, so we answer with the full page. This check is enough for this page because it has no `hxBoost` links; a page that uses `hxBoost` must also check `HxBoosted`, since a boosted link needs the full page too, as [How to Return HTMX Fragments, Full Pages and Response Headers](return-htmx-fragments-and-response-headers.md) shows. Now the page itself takes the current search, so a full page loaded from `/search?q=zio&category=Effects` shows "zio" in the box, "Effects" selected and the matching books: ```scala def searchBox(query: String): Dom.Element.Void = input( `type` := "search", name := "q", value := query, placeholder := "Search by title or author", hxGet := "/search", hxTrigger := debounced, hxTarget := HxTarget.css("#results"), hxInclude := HxTarget.css("#filters"), hxPushUrl := HxUrlUpdate(true) ) def categorySelect(current: String): Dom.Element = select( name := "category", hxGet := "/search", hxTarget := HxTarget.css("#results"), hxInclude := HxTarget.css("#filters"), hxPushUrl := HxUrlUpdate(true) )( option(value := "")("All categories") :: categories.map(category => option(value := category)(category).when(category == current)(selected)) ) def searchPage(query: String, category: String): Dom.Element = html(lang := "en")( head(meta(charset := "utf-8"), title("Scala Bookshelf"), htmxScript), body( h1("Scala Bookshelf"), div(id := "filters", searchBox(query), categorySelect(category)), div(id := "results", resultList(findBooks(query, category))) ) ) def searchApp(request: Request): Response = (request.method, request.path.segments.toList) match { case (Method.GET, Nil) | (Method.GET, List("search")) => val query = request.queryParams.getFirst("q").getOrElse("") val category = request.queryParams.getFirst("category").getOrElse("") val html = if (wantsFragment(request)) resultList(findBooks(query, category)).render else doctype.render + searchPage(query, category).render htmlResponse(html).addHeader("vary", "hx-request") case _ => notFound(request) } ``` The code above: - `hxPushUrl := HxUrlUpdate(true)` becomes `hx-push-url="true"` on both controls, so every search lands in the address bar. - `value := query` fills the search box. The HTML module escapes attribute values, so a search for `">Scala BookshelfZionomicon by John A. De Goes, Adam Fraser and Milad Khajavi ``` The same URL gave two answers. The htmx request got the one-item list, and the reload got the whole page with `value="zio"` in the search box and `selected` on the "Effects" option. That is the pattern for every htmx page: build the page from the request's parameters, and let one header decide how much of it to send. ## 8. Putting It Together This example combines all the concepts we've learned into one file. It serves the search page with the JDK HTTP adapter, and it keeps running until we stop it, so we can try it in a browser: ```scala title="zio-blocks-htmx-examples/src/main/scala/addinteractivitywithhtmx/CompleteExample.scala" package addinteractivitywithhtmx /** * Add Interactivity to a Page with HTMX — Complete Example * * A book search page served by the JDK HTTP server. The search box updates the * results 300 milliseconds after the reader stops typing, the category * drop-down filters them, and each search is pushed into the address bar. The * same `/search` URL answers an htmx request with the result list only and any * other request with the full page. * * Run with: cd zio-blocks-htmx-examples && sbt "runMain * addinteractivitywithhtmx.CompleteExample", then open http://localhost:8080/ */ object CompleteExample { final case class Book(title: String, author: String, category: String) val books: List[Book] = List( Book("Zionomicon", "John A. De Goes, Adam Fraser and Milad Khajavi", "Effects"), Book("Essential Effects", "Adam Rosien", "Effects"), Book("Practical FP in Scala", "Gabriel Volpe", "Effects"), Book("Functional Programming in Scala", "Paul Chiusano and Rúnar Bjarnason", "Functional Programming"), Book("Scala with Cats", "Noel Welsh and Dave Gurnell", "Functional Programming"), Book("Programming in Scala", "Martin Odersky, Lex Spoon and Bill Venners", "Language"), Book("Scala for the Impatient", "Cay S. Horstmann", "Language"), Book("Hands-on Scala Programming", "Li Haoyi", "Language") ) val categories: List[String] = List("Effects", "Functional Programming", "Language") def findBooks(query: String, category: String): List[Book] = { val needle = query.trim.toLowerCase books.filter { book => val matchesText = needle.isEmpty || book.title.toLowerCase.contains(needle) || book.author.toLowerCase.contains(needle) val matchesCategory = category.isEmpty || book.category == category matchesText && matchesCategory } } // The fragment htmx swaps into #results. def resultList(found: List[Book]): Dom.Element = if (found.isEmpty) p("No books match your search.") else ul(found.map(book => li(strong(book.title), s" by ${book.author}"))) // Both controls send GET /search with every field inside #filters, swap the // answer into #results and push the request URL into the address bar. def searchBox(query: String): Dom.Element.Void = input( `type` := "search", name := "q", value := query, placeholder := "Search by title or author", hxGet := "/search", hxTrigger := HxTrigger("keyup").changed.delay(300.millis), hxTarget := HxTarget.css("#results"), hxInclude := HxTarget.css("#filters"), hxPushUrl := HxUrlUpdate(true) ) def categoryFilter(current: String): Dom.Element = select( name := "category", hxGet := "/search", hxTarget := HxTarget.css("#results"), hxInclude := HxTarget.css("#filters"), hxPushUrl := HxUrlUpdate(true) )( option(value := "")("All categories") :: categories.map(category => option(value := category)(category).when(category == current)(selected)) ) def page(query: String, category: String): Dom.Element = html(lang := "en")( head( meta(charset := "utf-8"), title("Scala Bookshelf"), script(src := "https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js") ), body( h1("Scala Bookshelf"), div(id := "filters", searchBox(query), categoryFilter(category)), div(id := "results", resultList(findBooks(query, category))) ) ) // htmx 2 also sends HX-Request on a history cache miss, and then it needs the whole page. def wantsFragment(request: Request): Boolean = request.header(HxRequest).exists(_.enabled) && !request.header(HxHistoryRestoreRequest).exists(_.enabled) def htmlResponse(html: String): Response = Response.ok(Body.fromArray(html.getBytes("UTF-8"), ContentType.`text/html`)) def searchPage(request: Request): Response = { val query = request.queryParams.getFirst("q").getOrElse("") val category = request.queryParams.getFirst("category").getOrElse("") val html = if (wantsFragment(request)) resultList(findBooks(query, category)).render else doctype.render + page(query, category).render htmlResponse(html).addHeader("vary", "hx-request") } def app(request: Request): Response = (request.method, request.path.segments.toList) match { case (Method.GET, Nil) | (Method.GET, List("search")) => searchPage(request) case _ => Response.text(Status.NotFound, s"No route for ${request.method.name} ${request.path.encode}") } def main(args: Array[String]): Unit = { val port = args.headOption.flatMap(_.toIntOption).getOrElse(8080) val server = JdkHttp.start(app, port) sys.addShutdownHook(server.stop(0)) println(s"Open http://localhost:$port/ in a browser. Press Ctrl+C to stop.") } } ``` The pieces are the ones from the previous sections: `findBooks` and `resultList` from section 3, the debounced search box from section 5, the category filter with `hxInclude` from section 6, and `hxPushUrl` with the `wantsFragment` check from section 7. The `main` method starts the server on port 8080, or on the port given as the first argument, and stops it on Ctrl+C. Run it and open `http://localhost:8080/` in a browser. Type "scala" in the search box: the list updates once you pause, and the address bar changes to `/search?q=scala&category=`. Pick "Language": the list narrows to three books. Reload the page: the search box still says "scala" and "Language" is still selected. Press the Back button: the previous search comes back. Open the browser's developer tools on the Network tab to watch htmx send one request per pause, each with the `HX-Request: true` header. ## 9. Running the Examples All examples in this tutorial have corresponding runnable Scala files in the `zio-blocks-htmx-examples` project. To get them, clone the repository and change into the examples project: ```bash git clone https://github.com/zio/zio-blocks.git cd zio-blocks/zio-blocks-htmx-examples ``` Each step starts the server on port 8080, prints what the server answers to the requests from that section, and keeps running so you can open `http://localhost:8080/` in a browser. Press Ctrl+C to stop it before running the next step. To use another port, pass it as an argument, for example `sbt "runMain addinteractivitywithhtmx.Step1StaticPageExample 9090"`. The steps share the book list and helpers in `Bookshelf.scala`, and the server adapter in `JdkHttp.scala` and the client in `Client.scala` are copied from the guide [How to Serve http-model Requests with the JDK HTTP Server](./serve-http-model-requests.md). ### Share the Book List and Helpers Every step below imports the book model, the search function and the response helpers from this file. It has no `main`, so there is nothing to run; read it first.
zio-blocks-htmx-examples/src/main/scala/addinteractivitywithhtmx/Bookshelf.scala ```scala title="zio-blocks-htmx-examples/src/main/scala/addinteractivitywithhtmx/Bookshelf.scala" showLineNumbers package addinteractivitywithhtmx /** * Add Interactivity to a Page with HTMX — the bookshelf model * * Eight Scala books, a search function, the HTML fragment that lists search * results, the htmx script tag and a helper that wraps rendered HTML in a * `text/html` response. Every step of the tutorial reuses these. */ final case class Book(title: String, author: String, category: String) object Bookshelf { val books: List[Book] = List( Book("Zionomicon", "John A. De Goes, Adam Fraser and Milad Khajavi", "Effects"), Book("Essential Effects", "Adam Rosien", "Effects"), Book("Practical FP in Scala", "Gabriel Volpe", "Effects"), Book("Functional Programming in Scala", "Paul Chiusano and Rúnar Bjarnason", "Functional Programming"), Book("Scala with Cats", "Noel Welsh and Dave Gurnell", "Functional Programming"), Book("Programming in Scala", "Martin Odersky, Lex Spoon and Bill Venners", "Language"), Book("Scala for the Impatient", "Cay S. Horstmann", "Language"), Book("Hands-on Scala Programming", "Li Haoyi", "Language") ) val categories: List[String] = List("Effects", "Functional Programming", "Language") /** * Keeps the books whose title or author contains `query` (ignoring case) and * whose category is `category`. An empty query or category matches every * book. */ def findBooks(query: String, category: String): List[Book] = { val needle = query.trim.toLowerCase books.filter { book => val matchesText = needle.isEmpty || book.title.toLowerCase.contains(needle) || book.author.toLowerCase.contains(needle) val matchesCategory = category.isEmpty || book.category == category matchesText && matchesCategory } } /** The HTML fragment that lists the books, or says that nothing matched. */ def resultList(found: List[Book]): Dom.Element = if (found.isEmpty) p("No books match your search.") else ul(found.map(book => li(strong(book.title), s" by ${book.author}"))) /** Loads htmx 2.0.11 from the jsDelivr CDN. */ val htmxScript: Dom.Element = script(src := "https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js") /** * Wraps rendered HTML in a 200 OK response with `Content-Type: text/html`. */ def htmlResponse(html: String): Response = Response.ok(Body.fromArray(html.getBytes("UTF-8"), ContentType.`text/html`)) /** Answers 404 for any route the step does not handle. */ def notFound(request: Request): Response = Response.text(Status.NotFound, s"No route for ${request.method.name} ${request.path.encode}") /** * Starts the server on the port given as the first argument (8080 by default) * and says where to open it. The server keeps running until the program is * stopped with Ctrl+C. */ def serve(app: Request => Response, args: Array[String]): Int = { val port = args.headOption.flatMap(_.toIntOption).getOrElse(8080) val server = JdkHttp.start(app, port) sys.addShutdownHook(server.stop(0)) port } def openInBrowser(port: Int): Unit = println(s"Open http://localhost:$port/ in a browser. Press Ctrl+C to stop.") } ```
### Serve a Page That Loads htmx This example serves the static book list with the htmx script in its head.
zio-blocks-htmx-examples/src/main/scala/addinteractivitywithhtmx/Step1StaticPageExample.scala ```scala title="zio-blocks-htmx-examples/src/main/scala/addinteractivitywithhtmx/Step1StaticPageExample.scala" showLineNumbers package addinteractivitywithhtmx /** * Add Interactivity to a Page with HTMX — Step 1: A Static Page That Loads htmx * * Serves the bookshelf as an ordinary server-rendered page whose head loads the * htmx script. Nothing on the page is interactive yet. * * Run with: cd zio-blocks-htmx-examples && sbt "runMain * addinteractivitywithhtmx.Step1StaticPageExample" */ object Step1StaticPageExample { def page: Dom.Element = html(lang := "en")( head( meta(charset := "utf-8"), title("Scala Bookshelf"), htmxScript ), body( h1("Scala Bookshelf"), div(id := "results", resultList(books)) ) ) def app(request: Request): Response = (request.method, request.path.segments.toList) match { case (Method.GET, Nil) => htmlResponse(doctype.render + page.render) case _ => notFound(request) } def main(args: Array[String]): Unit = { val port = serve(app, args) Client.call(port, "GET", "/") openInBrowser(port) } } ```
Run it from the `zio-blocks-htmx-examples` directory: ```bash sbt "runMain addinteractivitywithhtmx.Step1StaticPageExample" ``` ### Make Your First htmx Request This example adds a button that loads the book list from `GET /books` into `#results`.
zio-blocks-htmx-examples/src/main/scala/addinteractivitywithhtmx/Step2FirstHtmxRequestExample.scala ```scala title="zio-blocks-htmx-examples/src/main/scala/addinteractivitywithhtmx/Step2FirstHtmxRequestExample.scala" showLineNumbers package addinteractivitywithhtmx /** * Add Interactivity to a Page with HTMX — Step 2: Your First htmx Request * * A button with `hx-get` and `hx-target` asks the server for the `/books` * fragment and swaps it into the `#results` element, without reloading the * page. * * Run with: cd zio-blocks-htmx-examples && sbt "runMain * addinteractivitywithhtmx.Step2FirstHtmxRequestExample" */ object Step2FirstHtmxRequestExample { val showAllButton: Dom.Element = button(hxGet := "/books", hxTarget := HxTarget.css("#results"))("Show all books") def page: Dom.Element = html(lang := "en")( head( meta(charset := "utf-8"), title("Scala Bookshelf"), htmxScript ), body( h1("Scala Bookshelf"), showAllButton, div(id := "results", p("Click the button to load the books.")) ) ) def app(request: Request): Response = (request.method, request.path.segments.toList) match { case (Method.GET, Nil) => htmlResponse(doctype.render + page.render) case (Method.GET, List("books")) => htmlResponse(resultList(books).render) case _ => notFound(request) } def main(args: Array[String]): Unit = { val port = serve(app, args) Client.call(port, "GET", "/books") openInBrowser(port) } } ```
Run it from the `zio-blocks-htmx-examples` directory: ```bash sbt "runMain addinteractivitywithhtmx.Step2FirstHtmxRequestExample" ``` ### Search as the Reader Types This example replaces the button with a debounced search box backed by `GET /search?q=...`.
zio-blocks-htmx-examples/src/main/scala/addinteractivitywithhtmx/Step3LiveSearchExample.scala ```scala title="zio-blocks-htmx-examples/src/main/scala/addinteractivitywithhtmx/Step3LiveSearchExample.scala" showLineNumbers package addinteractivitywithhtmx /** * Add Interactivity to a Page with HTMX — Step 3: A Debounced Live Search * * A search box sends `GET /search?q=...` 300 milliseconds after the reader * stops typing, and only when the text has changed. The server answers with the * matching books as a fragment. * * Run with: cd zio-blocks-htmx-examples && sbt "runMain * addinteractivitywithhtmx.Step3LiveSearchExample" */ object Step3LiveSearchExample { val searchBox: Dom.Element.Void = input( `type` := "search", name := "q", placeholder := "Search by title or author", hxGet := "/search", hxTrigger := HxTrigger("keyup").changed.delay(300.millis), hxTarget := HxTarget.css("#results") ) def page: Dom.Element = html(lang := "en")( head( meta(charset := "utf-8"), title("Scala Bookshelf"), htmxScript ), body( h1("Scala Bookshelf"), searchBox, div(id := "results", resultList(books)) ) ) def app(request: Request): Response = (request.method, request.path.segments.toList) match { case (Method.GET, Nil) => htmlResponse(doctype.render + page.render) case (Method.GET, List("search")) => val query = request.queryParams.getFirst("q").getOrElse("") htmlResponse(resultList(findBooks(query, "")).render) case _ => notFound(request) } def main(args: Array[String]): Unit = { val port = serve(app, args) Client.call(port, "GET", "/search?q=zio") Client.call(port, "GET", "/search?q=haskell") openInBrowser(port) } } ```
Run it from the `zio-blocks-htmx-examples` directory: ```bash sbt "runMain addinteractivitywithhtmx.Step3LiveSearchExample" ``` ### Filter by Category This example adds the category drop-down and uses `hxInclude` so both controls send both values.
zio-blocks-htmx-examples/src/main/scala/addinteractivitywithhtmx/Step4CategoryFilterExample.scala ```scala title="zio-blocks-htmx-examples/src/main/scala/addinteractivitywithhtmx/Step4CategoryFilterExample.scala" showLineNumbers package addinteractivitywithhtmx /** * Add Interactivity to a Page with HTMX — Step 4: A Category Filter * * Adds a category drop-down next to the search box. Both controls send * `GET /search` with `hx-include` pointing at their shared `#filters` * container, so every request carries the search text and the category. * * Run with: cd zio-blocks-htmx-examples && sbt "runMain * addinteractivitywithhtmx.Step4CategoryFilterExample" */ object Step4CategoryFilterExample { val searchBox: Dom.Element.Void = input( `type` := "search", name := "q", placeholder := "Search by title or author", hxGet := "/search", hxTrigger := HxTrigger("keyup").changed.delay(300.millis), hxTarget := HxTarget.css("#results"), hxInclude := HxTarget.css("#filters") ) val categoryFilter: Dom.Element = select( name := "category", hxGet := "/search", hxTarget := HxTarget.css("#results"), hxInclude := HxTarget.css("#filters") )( option(value := "")("All categories") :: categories.map(category => option(value := category)(category)) ) def page: Dom.Element = html(lang := "en")( head( meta(charset := "utf-8"), title("Scala Bookshelf"), htmxScript ), body( h1("Scala Bookshelf"), div(id := "filters", searchBox, categoryFilter), div(id := "results", resultList(books)) ) ) def app(request: Request): Response = (request.method, request.path.segments.toList) match { case (Method.GET, Nil) => htmlResponse(doctype.render + page.render) case (Method.GET, List("search")) => val query = request.queryParams.getFirst("q").getOrElse("") val category = request.queryParams.getFirst("category").getOrElse("") htmlResponse(resultList(findBooks(query, category)).render) case _ => notFound(request) } def main(args: Array[String]): Unit = { val port = serve(app, args) Client.call(port, "GET", "/search?q=&category=Effects") Client.call(port, "GET", "/search?q=scala&category=Language") openInBrowser(port) } } ```
Run it from the `zio-blocks-htmx-examples` directory: ```bash sbt "runMain addinteractivitywithhtmx.Step4CategoryFilterExample" ``` ### One URL, a Full Page or a Fragment This example pushes each search into the address bar and answers `/search` with a full page or a fragment, depending on the `HX-Request` header.
zio-blocks-htmx-examples/src/main/scala/addinteractivitywithhtmx/Step5FullPageOrFragmentExample.scala ```scala title="zio-blocks-htmx-examples/src/main/scala/addinteractivitywithhtmx/Step5FullPageOrFragmentExample.scala" showLineNumbers package addinteractivitywithhtmx /** * Add Interactivity to a Page with HTMX — Step 5: One URL, a Full Page or a * Fragment * * The controls push each search into the address bar with `hx-push-url`, so * `/search?q=...` must also work when the browser loads it directly. The server * checks the `HX-Request` header: an htmx request gets the result list only, * and any other request gets the whole page with the controls filled in. * * Run with: cd zio-blocks-htmx-examples && sbt "runMain * addinteractivitywithhtmx.Step5FullPageOrFragmentExample" */ object Step5FullPageOrFragmentExample { def searchBox(query: String): Dom.Element.Void = input( `type` := "search", name := "q", value := query, placeholder := "Search by title or author", hxGet := "/search", hxTrigger := HxTrigger("keyup").changed.delay(300.millis), hxTarget := HxTarget.css("#results"), hxInclude := HxTarget.css("#filters"), hxPushUrl := HxUrlUpdate(true) ) def categoryFilter(current: String): Dom.Element = select( name := "category", hxGet := "/search", hxTarget := HxTarget.css("#results"), hxInclude := HxTarget.css("#filters"), hxPushUrl := HxUrlUpdate(true) )( option(value := "")("All categories") :: categories.map(category => option(value := category)(category).when(category == current)(selected)) ) def page(query: String, category: String): Dom.Element = html(lang := "en")( head( meta(charset := "utf-8"), title("Scala Bookshelf"), htmxScript ), body( h1("Scala Bookshelf"), div(id := "filters", searchBox(query), categoryFilter(category)), div(id := "results", resultList(findBooks(query, category))) ) ) /** * True when htmx sent the request to swap part of the page. On a history * cache miss htmx 2 sends `HX-Request: true` as well, together with * `HX-History-Restore-Request: true`, and then it needs the whole page. */ def wantsFragment(request: Request): Boolean = request.header(HxRequest).exists(_.enabled) && !request.header(HxHistoryRestoreRequest).exists(_.enabled) def searchPage(request: Request): Response = { val query = request.queryParams.getFirst("q").getOrElse("") val category = request.queryParams.getFirst("category").getOrElse("") val html = if (wantsFragment(request)) resultList(findBooks(query, category)).render else doctype.render + page(query, category).render htmlResponse(html).addHeader("vary", "hx-request") } def app(request: Request): Response = (request.method, request.path.segments.toList) match { case (Method.GET, Nil) | (Method.GET, List("search")) => searchPage(request) case _ => notFound(request) } def main(args: Array[String]): Unit = { val port = serve(app, args) Client.call(port, "GET", "/search?q=zio&category=Effects") val htmxRequest = Request.get(URL.parse("/search?q=zio&category=Effects").toOption.get).addHeader(HxRequest(true)) println("htmx request for /search?q=zio&category=Effects ->") println(app(htmxRequest).body.asString()) openInBrowser(port) } } ```
Run it from the `zio-blocks-htmx-examples` directory: ```bash sbt "runMain addinteractivitywithhtmx.Step5FullPageOrFragmentExample" ``` ### The Complete Example The complete example from "Putting It Together" is a single file with everything in it: ```bash sbt "runMain addinteractivitywithhtmx.CompleteExample" ``` To compile every example at once without running them: ```bash sbt compile ``` ## 10. What You've Learned In this tutorial, you learned: - That htmx reads `hx-*` attributes on ordinary HTML elements, sends a request when something happens to the element, and swaps the server's HTML fragment into the page - How the typed keys `hxGet`, `hxTarget`, `hxTrigger`, `hxInclude` and `hxPushUrl` attach to elements built with the HTML DSL, and how values such as `HxTarget` and `HxUrlUpdate` keep them correct at compile time - How `HxTrigger("keyup").changed.delay(300.millis)` debounces a search box so the server is asked only when the reader pauses and the text has changed - How `hxInclude` makes several controls send their values together - How one route answers with a full page or a fragment by reading the typed `HxRequest` header, and why a history restore needs the full page You now have a solid foundation in building interactive pages with htmx and ZIO Blocks. The next step is to explore the other `hx-*` attributes and the response headers that let the server steer the browser. ## 11. Where to Go Next - **Want every attribute and value type?** Read the [HTMX](../reference/htmx/index.md) reference page, then the pages for [`HxTrigger`](../reference/htmx/hx-trigger.md), [`HxTarget`](../reference/htmx/hx-target.md) and [`HxSwap`](../reference/htmx/hx-swap.md). - **Want the server to steer the browser?** [Request and Response Headers](../reference/htmx/response-headers.md) covers the typed request headers and the response headers that redirect, retarget or refresh the page. - **Want the server to send full pages, out-of-band swaps and response headers?** [How to Return HTMX Fragments, Full Pages and Response Headers](./return-htmx-fragments-and-response-headers.md) shows one URL answering with a page or a fragment, and the headers that trigger events, redirect and retarget after a form post. - **Need stricter query parameters?** [How to Validate Query Parameters and Headers and Answer 400](./validate-query-parameters-and-headers.md) decodes typed parameters and reports every mistake at once. - **Want more on serving?** [How to Serve http-model Requests with the JDK HTTP Server](./serve-http-model-requests.md) explains the adapter the examples use and its limits. - **Prefer reactive signals?** [Build a Live Counter and Dashboard with Datastar](./build-a-live-dashboard-with-datastar.md) builds a counter and a streamed dashboard with typed `data-*` attributes and server-sent events; the [Datastar](../reference/datastar/index.md) reference covers every attribute. --- ## Getting Started with Async Welcome! This tutorial introduces `Async[A]`, a zero-allocation effect type from ZIO Blocks that unifies ready values, failures, and genuinely suspended computations under a single type and combinator set. If you know basic Scala syntax and have a sense of what an effect type is, you have everything you need to follow along. ## 1. Introduction By the end of this tutorial, you will be able to: - Create ready-value effects with `Async.succeed` and transform them with `Async#map` and `Async#block`. - Handle errors with `Async.fail`, `Async#catchAll`, and `Async#either`. - Write sequential async code in direct style using `Async.async { … .await … }`. - Bridge throw-based code and callback-based APIs with `Async.attempt` and `Async.promise` / `Completer`. - Fork background computations with `Async.start` and cancel them with `Async.Running#cancel`. To add the async module to your project, include this dependency in your `build.sbt`: ```scala libraryDependencies += "dev.zio" %%% "zio-blocks-async" % "0.0.56" ``` Then bring the full DSL into scope at the top of each file you use it from: ```scala ``` We recommend reading from top to bottom — each section builds directly on the one before it. ## 2. Background: What Is `Async[A]`? `Async[A]` was designed to solve a specific problem: code often juggles three different kinds of values — results that are already available, computations that need to wait for I/O or a callback, and failures. Treating these differently in different places creates friction. `Async[A]` is one abstraction that covers all three. The design's most important property is its **happy-path allocation budget: zero**. When you chain `Async.succeed`, `Async#map`, and another `map` call together, every step is a plain function call. No wrapper objects accumulate on the heap. Only a computation that truly suspends — waiting for a callback, a timer, or a thread — leaves a pending object behind. This is the zero-allocation promise: you pay for suspension only when you actually suspend. We will explore `Async[A]` through an order-processing scenario: looking up a user via a callback API, parsing an order ID, checking stock availability in the background, and recovering gracefully from any failure — one concept at a time. ## 3. Ready Values: `Async.succeed`, `map`, and `block` The simplest async computation is one that already has its answer. `Async.succeed(value)` wraps an available value into an `Async[A]` so it can take part in async chains without allocating anything. Once you have an `Async`, you transform it with `Async#map` and drive it to its final result with `Async#block`. Here we wrap the integer `42`, double it with `map`, then extract the result: ```scala val result: Int = Async.succeed(42).map(_ * 2).block println(s"Ready mapped: $result") ``` Expected output: ```text Ready mapped: 84 ``` - `Async.succeed(42)` wraps `42` as a ready-value `Async[Int]` with zero allocation. - Calling `map` with `_ * 2` applies the function; because the input is already ready, the entire chain is just a function call — no suspension object is created. - The `block` call is the eager driver — it polls the async until it completes and returns the final value. It is safe to call on the JVM; on Scala.js it throws if the computation is still suspended. Try changing `42` to a different number and watch the output change accordingly.
Runnable version: Concept1ReadyValuesExample.scala ```scala title="async-examples/src/main/scala/zio/blocks/async/gettingstarted/Concept1ReadyValuesExample.scala" showLineNumbers //> using scala 3.9.0 //> using dep dev.zio::zio-blocks-async:0.0.56 // Run from this folder: scala-cli run Concept1ReadyValuesExample.scala // Or from async-examples: sbt "runMain zio.blocks.async.gettingstarted.Concept1ReadyValuesExample" package zio.blocks.async.gettingstarted /** * Getting Started with Async — Ready Values: `Async.succeed`, `map`, and * `block` * * Wraps an available value with `Async.succeed`, transforms it with * `Async#map`, and drives it to its result with `Async#block`. */ object Concept1ReadyValuesExample { def main(args: Array[String]): Unit = { val result: Int = Async.succeed(42).map(_ * 2).block println(s"Ready mapped: $result") } } ```
## 4. Error Handling: `Async.fail`, `catchAll`, and `either` Not every computation succeeds. `Async.fail(throwable)` creates a failed async that short-circuits all downstream `map` calls without invoking their functions. `Async#catchAll` recovers by applying a function that returns a new `Async`. Here we create a failed computation and recover from it with `catchAll`: ```scala val recovered: String = Async .fail(new Exception("oops")) .catchAll(_ => Async.succeed("default")) .block println(s"Recovered: $recovered") ``` Expected output: ```text Recovered: default ``` - `Async.fail(new Exception("oops"))` creates a computation that carries the exception as its failure. - Calling `catchAll` with a recovery function intercepts the failure; the lambda ignores the specific error and returns a ready-value replacement. - The `block` call drives the recovered chain to its result, `"default"`. Sometimes you want to observe both the success and failure branches as plain data rather than handle them immediately. `Async#either` reifies both outcomes as `Either[Throwable, A]` so you can pattern-match on them: ```scala val observed: Either[Throwable, Int] = Async .fail(new Exception("error")) .either .block println(s"Observed: $observed") ``` Expected output: ```text Observed: Left(java.lang.Exception: error) ``` - Calling `either` wraps the failure in `Left`; a successful result would appear in `Right`, turning the async's outcome into an ordinary Scala value. - The `block` call materialises that `Either` so the learner can inspect it.
Runnable version, with both blocks of this section: Concept2ErrorHandlingExample.scala ```scala title="async-examples/src/main/scala/zio/blocks/async/gettingstarted/Concept2ErrorHandlingExample.scala" showLineNumbers //> using scala 3.9.0 //> using dep dev.zio::zio-blocks-async:0.0.56 // Run from this folder: scala-cli run Concept2ErrorHandlingExample.scala // Or from async-examples: sbt "runMain zio.blocks.async.gettingstarted.Concept2ErrorHandlingExample" package zio.blocks.async.gettingstarted /** * Getting Started with Async — Error Handling: `Async.fail`, `catchAll`, and * `either` * * Recovers a failed `Async` with `Async#catchAll`, then observes a failure as * data with `Async#either`. */ object Concept2ErrorHandlingExample { def main(args: Array[String]): Unit = { val recovered: String = Async .fail(new Exception("oops")) .catchAll(_ => Async.succeed("default")) .block println(s"Recovered: $recovered") val observed: Either[Throwable, Int] = Async .fail(new Exception("error")) .either .block println(s"Observed: $observed") } } ```
## 5. Direct Style: `Async.async` and `await` Writing nested `Async#flatMap` chains is precise but becomes hard to read when many steps depend on each other. `Async.async { … }` lets you write that same sequencing in direct style: inside the block, call `Async#await` on any `Async` to extract its value and bind it to a local variable, as if you were writing straight-line code. The compiler rewrites every `await` call into a `flatMap` chain at compile time, so the runtime behaviour is identical. Here we compose a user name and an order ID without a single explicit `flatMap`: ```scala val summary: String = Async.async { val user = Async.succeed("Ada").await val order = Async.succeed(9001).await s"${user}'s order ${order}" }.block println(s"Summary: $summary") ``` Expected output: ```text Summary: Ada's order 9001 ``` - `Async.async { … }` opens a macro-powered block; the entire expression produces an `Async[String]`. - Calling `await` on `Async.succeed("Ada")` extracts `"Ada"` and binds it to `user`; this is not a blocking call — the macro rewrites it into a `flatMap` continuation. - Calling `await` on `Async.succeed(9001)` similarly binds the order number to `order`. - The final string expression becomes the block's result value. - The `block` call drives the whole composed async to completion. :::caution[`await` Is Only Valid Inside `Async.async { … }`] The `await` method is enforced by the compiler to be used only inside an `Async.async { … }` block. On Scala 3 the inline expansion fails with: ``` ".await may only be used directly inside an Async.async { ... } block." ``` On Scala 2, the `@compileTimeOnly` annotation fires at the same point. Try deleting the `Async.async { … }` wrapper — the compiler will tell you immediately. :::
Runnable version: Concept3DirectStyleExample.scala ```scala title="async-examples/src/main/scala/zio/blocks/async/gettingstarted/Concept3DirectStyleExample.scala" showLineNumbers //> using scala 3.9.0 //> using dep dev.zio::zio-blocks-async:0.0.56 // Run from this folder: scala-cli run Concept3DirectStyleExample.scala // Or from async-examples: sbt "runMain zio.blocks.async.gettingstarted.Concept3DirectStyleExample" package zio.blocks.async.gettingstarted /** * Getting Started with Async — Direct Style: `Async.async` and `await` * * Sequences two `Async` values in direct style: each `.await` inside * `Async.async { ... }` is rewritten into a `flatMap` at compile time. */ object Concept3DirectStyleExample { def main(args: Array[String]): Unit = { val summary: String = Async.async { val user = Async.succeed("Ada").await val order = Async.succeed(9001).await s"${user}'s order ${order}" }.block println(s"Summary: $summary") } } ```
## 6. Bridging Exceptions: `Async.attempt` Scala code often signals failure by throwing exceptions rather than returning error values. `Async.attempt(body)` evaluates a block that may throw and captures any exception as an async failure, turning it into a value that `catchAll` can recover. A block that succeeds produces a ready-value `Async`; one that throws produces a failed `Async`. The example parses a well-formed string and then a malformed one: ```scala val good: Int = Async.attempt("42".toInt).block println(s"Parsed: $good") val bad: Either[Throwable, Int] = Async.attempt("oops".toInt).either.block println(s"Failed parse: $bad") ``` Expected output: ```text Parsed: 42 Failed parse: Left(java.lang.NumberFormatException: For input string: "oops") ``` - `Async.attempt("42".toInt)` evaluates `"42".toInt`; because it succeeds, the result `42` becomes a ready-value `Async[Int]`. - `Async.attempt("oops".toInt)` evaluates `"oops".toInt`; the `NumberFormatException` is caught and becomes a failed `Async`. - Chaining `either` and `block` drives the failed async and reifies its outcome as `Left(…)`.
Runnable version: Concept4BridgingExceptionsExample.scala ```scala title="async-examples/src/main/scala/zio/blocks/async/gettingstarted/Concept4BridgingExceptionsExample.scala" showLineNumbers //> using scala 3.9.0 //> using dep dev.zio::zio-blocks-async:0.0.56 // Run from this folder: scala-cli run Concept4BridgingExceptionsExample.scala // Or from async-examples: sbt "runMain zio.blocks.async.gettingstarted.Concept4BridgingExceptionsExample" package zio.blocks.async.gettingstarted /** * Getting Started with Async — Bridging Exceptions: `Async.attempt` * * Turns a throwing block into an `Async`: a well-formed string parses to a * ready value, a malformed one becomes a failure that `Async#either` reifies. */ object Concept4BridgingExceptionsExample { def main(args: Array[String]): Unit = { val good: Int = Async.attempt("42".toInt).block println(s"Parsed: $good") val bad: Either[Throwable, Int] = Async.attempt("oops".toInt).either.block println(s"Failed parse: $bad") } } ```
## 7. Callback Bridging: `Async.promise` and `Completer` Many real-world APIs — database drivers, network libraries, timers — signal completion by calling a callback rather than returning a value. `Async.promise` lets you lift these APIs into `Async` without rewriting them. It suspends the computation and provides a `Completer[A]` — a thread-safe, one-shot handle — that you can pass to the callback. Calling `completer.succeed(value)` from any thread resolves the async and wakes up any awaiter. The example starts a background thread that completes the promise after a short delay: ```scala val result: String = Async .promise[String] { c => new Thread { override def run(): Unit = { Thread.sleep(10) c.succeed("hello from callback") } }.start() } .block println(s"Promise resolved: $result") ``` ```scala val result: String = Async .promise[String] { val c = summon[Completer[String]] new Thread { override def run(): Unit = { Thread.sleep(10) c.succeed("hello from callback") } }.start() } .block println(s"Promise resolved: $result") ``` Expected output: ```text Promise resolved: hello from callback ``` - `Async.promise[String] { … }` opens the promise body; on Scala 2 the `Completer[String]` arrives as an explicit parameter `c`; on Scala 3 it arrives as a context function argument retrieved with `summon[Completer[String]]`. - We capture the completer in `c` so it can be referenced from the Thread's `run()` method — implicits and givens do not propagate across thread boundaries, so we capture explicitly. - Calling `c.succeed("hello from callback")` completes the promise; the first call wins and subsequent calls are no-ops. - The `block` call waits until the completer fires and returns the resolved value. :::warning[Scala 3: do not annotate the binder] On Scala 3, `Async.promise[String] { (c: Completer[String]) => ... }` compiles, but the statements inside the lambda never run, so the completer is never completed and `block` waits forever. Use the Scala 3 form above, `summon[Completer[String]]` or `{ c ?=> ... }`. Compiler warning E190 at the call site is the only hint. :::
Runnable version, the Scala 3 tab: Concept5CallbackBridgingExample.scala ```scala title="async-examples/src/main/scala/zio/blocks/async/gettingstarted/Concept5CallbackBridgingExample.scala" showLineNumbers //> using scala 3.9.0 //> using dep dev.zio::zio-blocks-async:0.0.56 // Run from this folder: scala-cli run Concept5CallbackBridgingExample.scala // Or from async-examples: sbt "runMain zio.blocks.async.gettingstarted.Concept5CallbackBridgingExample" package zio.blocks.async.gettingstarted /** * Getting Started with Async — Callback Bridging: `Async.promise` and * `Completer` * * The Scala 3 version of the section's code: `summon[Completer[String]]` * retrieves the completer, which a background thread completes after 10 ms. */ object Concept5CallbackBridgingExample { def main(args: Array[String]): Unit = { val result: String = Async .promise[String] { val c = summon[Completer[String]] new Thread { override def run(): Unit = { Thread.sleep(10) c.succeed("hello from callback") } }.start() } .block println(s"Promise resolved: $result") } } ```
## 8. Forking and Cancellation: `start` and `Async.Running` By default, async chains run eagerly on the calling thread until the first suspension or completion. To evaluate a block on a **background worker** (a separate JVM thread or a Scala.js microtask) instead, pass it to `Async.start`. This returns an `Async.Running[A]` handle — itself an `Async[A]` — representing the in-flight computation. You can join it by calling `block` on the handle, or stop waiting for it with `cancel`. The first block forks a computation and joins it, printing the name of the thread each step runs on: ```scala val running: Async.Running[Int] = Async.start { println(s"Computing on ${Thread.currentThread.getName}") 42 * 2 } val joined: Int = running.block println(s"Joined on ${Thread.currentThread.getName}: $joined") ``` Expected output: ```text Computing on zio-blocks-async-eval Joined on main: 84 ``` - `Async.start { … }` hands the block to a background worker and returns the `Running` handle immediately; the first line shows the block running on the worker thread, `zio-blocks-async-eval`. - `running.block` blocks the calling thread until the background computation finishes and returns the result `84`, back on `main`. The extension method `Async#start` drives an `Async` you have already built. Notice what happens when that `Async` is a chain of ready values: ```scala val ready: Async.Running[Int] = Async .succeed(42) .map { x => println(s"map runs on ${Thread.currentThread.getName}"); x * 2 } .start println(s"Joined: ${ready.block}") ``` Expected output: ```text map runs on main Joined: 84 ``` - The `map` function runs on `main`, while the chain is being built and before `start` is called: its input is already available, so there is nothing to wait for. - Calling `start` on the result finds a ready value and settles at once, without a background worker. This is the zero-allocation promise from section 2 at work — a chain leaves the calling thread only when it genuinely suspends, such as waiting on a `Completer`. The last block demonstrates cancellation: ```scala val running2: Async.Running[Int] = Async.start { Thread.sleep(100) println("Body finished after cancel") 99 } running2.cancel() println("Cancelled running2") Thread.sleep(200) // give the body time to finish ``` Expected output: ```text Cancelled running2 Body finished after cancel ``` - `Async.start { … }` forks the block onto a background worker; the call returns the `Running` handle immediately. - Calling `cancel` stops the driver, so no result is ever published to an awaiter. The call is idempotent. - The second line shows that `cancel` does not interrupt the block itself: it finishes on its own thread. Cancelling an `Async.start` means you have stopped waiting for the result, not that the work has stopped. :::caution A cancelled run never settles: it neither succeeds nor fails. Calling `block` on a cancelled handle waits forever on the JVM and throws `IllegalStateException` on Scala.js, so cancel only a handle whose every consumer you own. The [`Async.Running`](../reference/async.md#asyncrunning) reference covers the details. :::
Runnable version, with all three blocks of this section: Concept6ForkingAndCancellationExample.scala ```scala title="async-examples/src/main/scala/zio/blocks/async/gettingstarted/Concept6ForkingAndCancellationExample.scala" showLineNumbers //> using scala 3.9.0 //> using dep dev.zio::zio-blocks-async:0.0.56 // Run from this folder: scala-cli run Concept6ForkingAndCancellationExample.scala // Or from async-examples: sbt "runMain zio.blocks.async.gettingstarted.Concept6ForkingAndCancellationExample" package zio.blocks.async.gettingstarted /** * Getting Started with Async — Forking and Cancellation: `start` and * `Async.Running` * * Forks a block onto a background worker with `Async.start` and joins it, shows * that `Async#start` on a ready chain settles on the calling thread, and * cancels a running handle whose body still runs to completion. */ object Concept6ForkingAndCancellationExample { def main(args: Array[String]): Unit = { val running: Async.Running[Int] = Async.start { println(s"Computing on ${Thread.currentThread.getName}") 42 * 2 } val joined: Int = running.block println(s"Joined on ${Thread.currentThread.getName}: $joined") val ready: Async.Running[Int] = Async .succeed(42) .map { x => println(s"map runs on ${Thread.currentThread.getName}"); x * 2 } .start println(s"Joined: ${ready.block}") val running2: Async.Running[Int] = Async.start { Thread.sleep(100) println("Body finished after cancel") 99 } running2.cancel() println("Cancelled running2") Thread.sleep(200) // give the body time to finish } } ```
## 9. Putting It Together Let's combine all six concepts into a single order-processing pipeline. The program bridges a callback-based user-lookup API with `Async.promise`, safely parses an order ID with `Async.attempt`, runs a stock check in the background with `Async.start`, sequences everything in direct style inside `Async.async`, and recovers from any failure with `catchAll`. Each step reuses a section's code with order-processing values: the callback delivers the user name `"Ada"` instead of a greeting, `Async.attempt` parses the order ID `"9001"`, the background block is a stock check that sleeps 5 ms and returns `true`, and the recovery handler reports the failure's message instead of a fixed default: ```scala title="async-examples/src/main/scala/zio/blocks/async/gettingstarted/CompleteExample.scala" //> using scala 3.9.0 //> using dep dev.zio::zio-blocks-async:0.0.56 // Run from this folder: scala-cli run CompleteExample.scala // Or from async-examples: sbt "runMain zio.blocks.async.gettingstarted.CompleteExample" package zio.blocks.async.gettingstarted /** * Getting Started with Async — Putting It Together * * An order-processing pipeline assembled from the sections: a callback lookup * bridged with `Async.promise`, an order ID parsed with `Async.attempt`, a * stock check forked with `Async.start`, all sequenced in `Async.async` and * recovered with `Async#catchAll`. */ object CompleteExample { def main(args: Array[String]): Unit = { // Step 1: Bridge a callback-based user-lookup API with promise + Completer val userLookup: Async[String] = Async.promise[String] { val c = summon[Completer[String]] new Thread { override def run(): Unit = { Thread.sleep(10) c.succeed("Ada") } }.start() } // Step 2: Parse an order ID that might be malformed val orderId: Async[Int] = Async.attempt("9001".toInt) // Step 3: Start a stock-availability check on a background worker val stockCheck: Async.Running[Boolean] = Async.start { Thread.sleep(5); true } // Step 4: Compose all steps in direct style and recover from any failure val report: String = Async.async { val user = userLookup.await val id = orderId.await val inStock = stockCheck.await s"Order $id for $user: ${if (inStock) "in stock" else "out of stock"}" }.catchAll { err => Async.succeed(s"Order failed: ${err.getMessage}") }.block println(s"Report: $report") } } ``` Expected output: ```text Report: Order 9001 for Ada: in stock ``` ## 10. Running the Examples All runnable code from this tutorial is in [`async-examples/src/main/scala/zio/blocks/async/gettingstarted/`](https://github.com/zio/zio-blocks/tree/main/async-examples/src/main/scala/zio/blocks/async/gettingstarted). Run any file from that folder with `scala-cli run .scala`, or from `async-examples` with `sbt "runMain zio.blocks.async.gettingstarted."`. ## 11. What You've Learned By completing this tutorial, you can now: - Create ready-value effects with `Async.succeed`, transform them with `map`, and drive them to a result with `block`. - Handle failures with `Async.fail`, recover them with `catchAll`, and observe both outcomes as data with `either`. - Write sequential async pipelines in direct style using `Async.async { … .await … }` — the compiler threads the `flatMap` calls for you. - Lift throw-based Scala code safely into the async error channel with `Async.attempt`. - Bridge a callback-based API with `Async.promise` and `Completer`, fork background work with `Async.start`, and hold a cancellable `Async.Running` handle. ## 12. Where to Go Next If your code already calls `Future`-based, Java `CompletionStage`-based, or callback-based APIs, the next step is [How to Wrap Callback and Future-Based APIs in Async](./wrap-callback-and-future-apis.md), which brings each of them into `Async` and shows how to cancel the underlying call. The [Async reference page](../reference/async.md) documents every method and combinator with full signatures — it is the natural next stop once you are comfortable with the basics in this tutorial. If your application manages resources that need deterministic cleanup — database connections, file handles, or connection pools — read the [Compile-Time Resource Safety with Scope](./compile-time-resource-safety-with-scope.md) tutorial, which shows how to tie resource lifetimes to lexical scopes and compose them without try/finally boilerplate. --- ## How to Avoid Allocation on Hot Paths In this guide, we take a price-tick handler that runs for every trade on a market data feed and remove the allocations from its hot loop, one at a time. By the end, you will know how to keep a ZIO Blocks `Stream` of `Long` prices on its primitive lane, how to read and build a `Chunk` of prices without boxing them, how to keep per-tick state in a `Maybe` instead of an `Option`, and how to record a metric with fixed labels without building an `Attributes` value on every call. Every claim comes with a number we measured, and the last section shows how to measure your own code the same way. Each section is independent: pick the one that matches the allocation you have. This guide covers: - Measuring the bytes a loop allocates per element - Keeping stream elements on a primitive lane, and reading them in a custom `Sink` - Wrapping, reading and building `Chunk`s without boxing - Replacing `Option` with `Maybe` for state written on every tick - Pre-binding metric labels with `Counter#bind` - Checking the results with JMH and its GC profiler ## The Problem A loop that runs millions of times per second turns every small allocation into garbage-collector work. On a 64-bit JVM with compressed pointers, a boxed `java.lang.Long` takes 24 bytes, a `Some` 16, and a tuple key for a metrics map another 24; at a million ticks per second that is tens of megabytes of short-lived objects per second, which the JVM pays for in young-generation collections, cache misses and lost throughput. Generic Scala code allocates this way without saying so. A tick handler written with the standard library often looks like this: ```scala final case class Tick(symbol: String, priceMicros: Long, size: Int) final class TickHandler { var lastTick: Option[Tick] = None val counts = scala.collection.mutable.HashMap.empty[(String, String), Long] // Called for every trade def onTick(tick: Tick): Unit = { lastTick = Some(tick) val key = (tick.symbol, "XNAS") counts.update(key, counts.getOrElse(key, 0L) + 1L) } // Called for every batch of prices, in micro-dollars def turnoverCents(prices: Array[Long]): Long = prices.iterator.map(_ / 10000L).filter(_ > 18750L).foldLeft(0L)(_ + _) } ``` Nothing here looks expensive, yet on this machine `TickHandler#onTick` allocates 40 to 64 bytes per tick and `turnoverCents` about 62 bytes per price. The tick handler pays for the `Some`, the boxed count and, unless the JIT removes it, the tuple key; the turnover pays for a boxed `Long` at every step of `Iterator#map`, `Iterator#filter` and `Iterator#foldLeft`. None of it is visible in the source. ZIO Blocks has a tool for each of these allocations. In this guide, we replace them one by one and measure what each change saves. ## Prerequisites We use four modules: `streams`, `chunk`, `maybe` and `telemetry`. On Scala 2.13 and Scala 3.3 to 3.8, add: ```scala libraryDependencies ++= Seq( "dev.zio" %% "zio-blocks-streams" % "0.0.56", "dev.zio" %% "zio-blocks-chunk" % "0.0.56", "dev.zio" %% "zio-blocks-maybe" % "0.0.56", "dev.zio" %% "zio-blocks-telemetry" % "0.0.56" ) ``` Scala 3.9 and newer use the `zio-blocks-next-*` artifact names and need `scalacOptions += "-experimental"`; [How to Add ZIO Blocks to Your Build](../getting-started/installation.md) has the details. The `streams` and `telemetry` modules need JDK 21 or newer on the JVM. We assume you know what boxing is and have read [Your First Streaming Pipeline](./your-first-streaming-pipeline.md) or written a `Stream` before. The examples use these imports: ```scala ``` ## Measure Allocation Per Tick Before changing anything, we need a number. The JVM counts the bytes each thread allocates, and `com.sun.management.ThreadMXBean` exposes that counter, so a few lines give us bytes per run of any block. The block must run a few thousand times first, because the JIT compiler removes some allocations once it has compiled the code, and a cold measurement reports allocations that a hot loop never makes: ```scala object Allocation { private val threads = ManagementFactory.getThreadMXBean.asInstanceOf[com.sun.management.ThreadMXBean] @volatile private var sink: Int = 0 // Runs `body` until the JIT has compiled it, then returns the average number // of bytes one run allocates on the current thread. def bytesPerRun(body: => Any): Long = { var i = 0 while (i < 2000) { sink ^= System.identityHashCode(body); i += 1 } val runs = 200 val before = threads.getCurrentThreadAllocatedBytes i = 0 while (i < runs) { sink ^= System.identityHashCode(body); i += 1 } (threads.getCurrentThreadAllocatedBytes - before) / runs } } ``` The `sink` field keeps the JIT from discarding a result nobody reads. The counter is per thread, so the block must run on the calling thread, which every example in this guide does. The running example is a batch of 10,000 trades for one symbol. Prices are fixed-point `Long`s in micro-dollars, and every one of them lies far outside the small-value cache that `java.lang.Long.valueOf` keeps for -128 to 127, so boxing one always allocates: ```scala final case class Tick(symbol: String, priceMicros: Long, size: Int) val tickCount = 10000 val prices: Array[Long] = Array.tabulate(tickCount)(i => 187000000L + (i % 997) * 1300L) val sizes: Array[Int] = Array.tabulate(tickCount)(i => 100 + i % 50) val ticks: Array[Tick] = Array.tabulate(tickCount)(i => Tick("ACME", prices(i), sizes(i))) ``` Dividing `Allocation.bytesPerRun` by `tickCount` gives bytes per tick. The figures in this guide come from the companion examples and from the JMH benchmark in [Check the Numbers With JMH](#check-the-numbers-with-jmh), run on one machine with JDK 25; treat them as rough sizes, not guarantees. ## Keep a Stream on a Primitive Lane A ZIO Blocks `Stream` carries each element on a lane: one of the primitive JVM types, or `AnyRef` for objects. On a primitive lane a `Long` moves from the source through `Stream#map`, `Stream#filter` and the sink as a raw `long`, so the pipeline allocates nothing per element. The lane is picked at compile time from the element type each operator produces, so the same code that boxed with an `Iterator` stops boxing when it is a `Stream`: ```scala val iteratorBytes = Allocation.bytesPerRun { prices.iterator.map(_ / 10000L).filter(_ > 18750L).foldLeft(0L)(_ + _) } val streamBytes = Allocation.bytesPerRun { Stream.fromArray(prices).map(_ / 10000L).filter(_ > 18750L).runFold(0L)(_ + _) } println(s"Iterator: ${iteratorBytes.toDouble / tickCount} bytes per tick") println(s"Stream: ${streamBytes.toDouble / tickCount} bytes per tick") ``` On this machine the `Iterator` version allocates about 62 bytes per tick and the `Stream` version about 0.1: some 700 to 1,000 bytes per run of 10,000 ticks, for the stream's own nodes and the `Either` that `Stream#runFold` returns. Both compute the same turnover: ```scala val viaIterator = prices.iterator.map(_ / 10000L).filter(_ > 18750L).foldLeft(0L)(_ + _) // viaIterator: Long = 113490340L val viaStream = Stream.fromArray(prices).map(_ / 10000L).filter(_ > 18750L).runFold(0L)(_ + _) // viaStream: Either[Nothing, Long] = Right(113490340L) ``` `Stream.fromArray` reads straight from the array. `Stream.fromIterable` also picks the `Long` lane for a `List[Long]`, but the list already holds one boxed `Long` per element, and the stream unboxes them as it reads; build hot-path data in an array or a `Chunk` instead. ### See Which Lane a Stream Is On `Stream` has no method that reports its lane, but the reader a sink receives does: `Reader#jvmType` names the lane the elements arrive on. A one-line sink built with `Sink.create` (JVM only) turns that into a diagnostic: ```scala def lane[A]: Sink[Nothing, A, JvmType] = Sink.create[Nothing, A, JvmType](reader => reader.jvmType) ``` Running a few pipelines into it shows which operators keep the `Long` lane and which leave it: ```scala Stream.fromArray(prices).map(_ / 10000L).filter(_ > 18750L).run(lane) // res2: Either[Nothing, JvmType] = Right(Long) Stream.fromArray(prices).map(_.toDouble / 1e6).run(lane) // res3: Either[Nothing, JvmType] = Right(Double) Stream.fromArray(prices).scan(0L)(_ + _).run(lane) // res4: Either[Nothing, JvmType] = Right(Long) (Stream.fromArray(prices) && Stream.fromArray(sizes)).run(lane) // res5: Either[Nothing, JvmType] = Right(AnyRef) Stream.fromArray(prices).grouped(100).run(lane) // res6: Either[Nothing, JvmType] = Right(AnyRef) ``` The rules behind these results: | Operators | Lane of the result | | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | `Stream#filter`, `Stream#take`, `Stream#drop`, `Stream#takeWhile`, `Stream#tapEach` | The input's lane | | `Stream#map`, `Stream#collect`, `Stream#scan`, `Stream#mapAccum`, `Stream#flatMap`, `Stream#++` | The lane of the result type (`Long => Double` moves to `Double`) | | `Stream#&&` (zip), `Stream#grouped`, `Stream#chunked`, `Stream#sliding` | `AnyRef`: their elements are tuples or `Chunk`s | | Any operator whose result type is a reference or `Any` | `AnyRef` | None of these changes is reported at compile time. A `Stream#map` whose function returns `Any` (for example, an `if` with a `Long` in one branch and a `String` in the other) quietly moves the stream to the `AnyRef` lane and boxes every element from there on. ### Replace a Zip With an Index Zipping two primitive streams is the most common way to leave the lane by accident: `&&` pairs prices with sizes as tuples, so every element becomes an object. When both sides are arrays of the same length, a stream of indices does the same work on the `Long` lane: ```scala val zippedBytes = Allocation.bytesPerRun { (Stream.fromArray(prices) && Stream.fromArray(sizes)).map { case (p, s) => p * s }.runFold(0L)(_ + _) } val indexedBytes = Allocation.bytesPerRun { Stream.range(0, tickCount).map(i => prices(i) * sizes(i)).runFold(0L)(_ + _) } println(s"zipped: ${zippedBytes.toDouble / tickCount} bytes per tick") println(s"indexed: ${indexedBytes.toDouble / tickCount} bytes per tick") ``` The zipped version allocates about 55 bytes per tick on this machine, the indexed one under 0.05. Both compute the same notional value: ```scala (Stream.fromArray(prices) && Stream.fromArray(sizes)).map { case (p, s) => p * s }.runFold(0L)(_ + _) // res8: Either[Nothing, Long] = Right(233618271696500L) Stream.range(0, tickCount).map(i => prices(i) * sizes(i)).runFold(0L)(_ + _) // res9: Either[Nothing, Long] = Right(233618271696500L) ``` ### Read Primitives in a Custom Sink `Stream#runFold` and `Sink.foldLeft` with a `Long` start value read the lane and keep the running total unboxed. The other built-in aggregates also read the lane without boxing, but in 0.0.56 they box their running total once per element, a known bug: `Sink.sumLong`, `Sink.sumInt`, `Sink.sumDouble`, `Sink.count`, and `Stream#runFold` or `Sink.foldLeft` with a `Double` start value. Measured side by side: ```scala val sumSinkBytes = Allocation.bytesPerRun(Stream.fromArray(prices).run(Sink.sumLong)) val countSinkBytes = Allocation.bytesPerRun(Stream.fromArray(prices).run(Sink.count)) val runFoldBytes = Allocation.bytesPerRun(Stream.fromArray(prices).runFold(0L)(_ + _)) println(s"Sink.sumLong: ${sumSinkBytes.toDouble / tickCount} bytes per tick") println(s"Sink.count: ${countSinkBytes.toDouble / tickCount} bytes per tick") println(s"runFold(0L): ${runFoldBytes.toDouble / tickCount} bytes per tick") ``` On this machine `Sink.sumLong` and `Sink.count` allocate about 48 bytes per tick and `Stream#runFold` with `0L` nothing per tick, so on a hot path we sum and count with a `Long` fold instead (`runFold(0L)((n, _) => n + 1)` counts). When we need a sink of our own, we pull from the reader with its primitive reads instead of `Reader#read`, which returns a boxed element. For a `Long` stream, the read that never boxes and never mistakes a price for the end of the stream is `Reader#readLongs` with a one-element array: it copies the next element into the array and returns `1`, or returns `-1` once the stream has ended. A sink that tracks the highest price looks like this: ```scala val highestPrice: Sink[Nothing, Long, Long] = Sink.create[Nothing, Long, Long] { reader => val one = new Array[Long](1) var highest = Long.MinValue var n = reader.readLongs(one, 0, 1) while (n >= 0) { if (one(0) > highest) highest = one(0) n = reader.readLongs(one, 0, 1) } highest } ``` It reads every price on the `Long` lane: ```scala Stream.fromArray(prices).run(highestPrice) // res11: Either[Nothing, Long] = Right(188294800L) ``` Measured the same way as before, a run over 10,000 ticks allocates about 224 bytes in total, all of it per run (the stream, the reader and the one-element array), none of it per tick. `Sink.create` exists only on the JVM. On Scala.js, or to support both blocking and asynchronous runs, build the sink with `Sink.createBoth`, which takes one function for a `Reader.SyncReader` and one for a `Reader.AsyncReader`; both reader kinds have the same primitive reads. The [Sink reference](../reference/streams/core/sink.md) covers both factories. ### Choose an End-of-Stream Signal That Cannot Collide Each primitive lane has a read that widens the element into a larger type, so a sentinel outside the element's range can mark the end of the stream and no real element can be mistaken for it. `Reader#readInt(sentinel)` returns a `Long`, and the convention is to pass `Long.MinValue`; `Reader#readFloat(sentinel)` returns a `Double`, and the convention is to pass `Double.MaxValue`; `Reader#readByte()` returns `0` to `255`, or `-1` at the end. `Long` and `Double` have no wider type to escape into, so `Reader#readLong` and `Reader#readDouble` return the element type itself, and the sentinel we pass in can equal a real element. A real element equal to that marker ends the loop early, and nothing tells us it happened. For example, a sink that sums price changes and picks `0L` as its marker stops at the first unchanged price: ```scala val priceChanges = Array(5L, -3L, 0L, 7L) val sumUntilMarker: Sink[Nothing, Long, Long] = Sink.create[Nothing, Long, Long] { reader => var sum = 0L var value = reader.readLong(0L) while (value != 0L) { sum += value value = reader.readLong(0L) } sum } ``` It returns `2`, while the built-in `Sink.sumLong`, which uses the count-returning read, returns the full `9`: ```scala Stream.fromArray(priceChanges).run(sumUntilMarker) // res12: Either[Nothing, Long] = Right(2L) Stream.fromArray(priceChanges).run(Sink.sumLong) // res13: Either[Nothing, Long] = Right(9L) ``` No marker value is safe for every feed, so read `Long` and `Double` lanes with `Reader#readLongs` or `Reader#readDoubles` and a one-element array, as `highestPrice` does. The library's own sinks do the same, which is why `Sink.sumLong` keeps the zero. Do not add a check against the marker inside the loop to work around a collision: the count-returning read costs nothing extra per element and never collides. The [zero-boxing reference](../reference/streams/execution-and-compatibility/zero-boxing.md) lists the end-of-stream signal of every lane. ## Read and Build Chunks Without Boxing A `Chunk` built from a primitive array stores its elements in that array, unboxed. Three habits decide whether our code keeps that advantage: how we create the chunk, how we read it, and how we build a new one. ### Wrap an Array Instead of Copying It `Chunk.fromArray` wraps the array we already have; `Chunk.from` (like `Chunk.fromIterable`) copies it first: ```scala val copyBytes = Allocation.bytesPerRun(Chunk.from(prices)) val wrapBytes = Allocation.bytesPerRun(Chunk.fromArray(prices)) println(s"Chunk.from: $copyBytes bytes per batch") println(s"Chunk.fromArray: $wrapBytes bytes per batch") ``` On this machine `Chunk.from` allocates about 80,000 bytes per batch of 10,000 prices (8 bytes per price for the copy), and `Chunk.fromArray` allocates 32 bytes, the wrapper object alone. :::warning A chunk from `Chunk.fromArray` shares the array with the caller. Writing to the array afterwards changes the chunk, which breaks the promise that a `Chunk` is immutable. Wrap only arrays that nothing writes to again, such as a buffer we have just filled and handed over. ::: ### Read Elements With a Primitive Accessor `Chunk#foldLeft` is generic: its accumulator and each element pass through `(S, A) => S` as objects, so summing 10,000 prices allocates 24 to 48 bytes per price on this machine (one or two boxed `Long`s, depending on how much of the fold the JIT inlines). An index loop with `Chunk#long` reads each `Long` straight from the backing array: ```scala val priceChunk = Chunk.fromArray(prices) def totalMicros(chunk: Chunk[Long]): Long = { var sum = 0L var i = 0 while (i < chunk.length) { sum += chunk.long(i) i += 1 } sum } ``` Both give the same total, but the loop allocates nothing per price: ```scala priceChunk.foldLeft(0L)(_ + _) // res15: Long = 1876455143500L totalMicros(priceChunk) // res16: Long = 1876455143500L ``` The same accessor exists for every primitive: `Chunk#int`, `Chunk#double`, `Chunk#byte`, `Chunk#float`, `Chunk#short`, `Chunk#char` and `Chunk#boolean`. They read the array directly only when the chunk is backed by a primitive array, which is what `Chunk.fromArray`, `Chunk#map` on such a chunk, and the primitive builders below produce. A chunk made by `Chunk#++`, `Chunk#drop` or `Chunk#slice` is a view over other chunks, and its accessors go through `Chunk#apply`, which returns a boxed element; call `Chunk#materialize` once before the loop to get an array-backed copy. On an array-backed chunk, `Chunk#long` never boxes, whatever the JIT does. ### Build a Chunk With a Primitive Builder `Chunk.newBuilder[Long]` is generic, so it boxes each price before storing it. `ChunkBuilder.Long` stores raw `long`s, and `sizeHint` sizes its array once instead of doubling it as it grows: ```scala def pricesAbove(limitMicros: Long): Chunk[Long] = { val builder = new ChunkBuilder.Long builder.sizeHint(prices.length) var i = 0 while (i < prices.length) { if (prices(i) > limitMicros) builder.addOne(prices(i)) i += 1 } builder.result() } ``` The result is a chunk on a primitive array: ```scala pricesAbove(188000000L).length // res17: Int = 2270 ``` On this machine, adding all 10,000 prices costs about 58 bytes per price through `Chunk.newBuilder[Long]` and 16 through a sized `ChunkBuilder.Long`: 8 bytes for the builder's array and 8 for the copy that `ChunkBuilder#result` makes. `ChunkBuilder.Int`, `ChunkBuilder.Double`, `ChunkBuilder.Byte` and the other primitive builders work the same way. ## Replace Option With Maybe for Per-Tick State State that changes on every tick, such as the last trade seen, is a field written in the hot loop. With `Option`, every write allocates a new `Some`, and because the `Some` is stored in a field, the JIT cannot remove it. On Scala 3, `Maybe` stores a present reference as the reference itself, so writing it allocates nothing: ```scala final class LastTrade { var tick: Maybe[Tick] = Maybe.absent def onTick(t: Tick): Unit = tick = Maybe(t) def lastPriceMicros: Long = tick.fold(0L)(_.priceMicros) } ``` We feed it a batch of ticks: ```scala val lastTrade = new LastTrade ticks.foreach(lastTrade.onTick) ``` The state now holds the last tick's price: ```scala lastTrade.lastPriceMicros // res19: Long = 187037700L ``` To compare it with the `Option` version, we write every tick of the batch into each kind of field: ```scala final class OptionLastTrade { var tick: Option[Tick] = None } val optionTrade = new OptionLastTrade val maybeTrade = new LastTrade val optionBytes = Allocation.bytesPerRun { var i = 0 while (i < tickCount) { optionTrade.tick = Some(ticks(i)); i += 1 } optionTrade } val maybeBytes = Allocation.bytesPerRun { var i = 0 while (i < tickCount) { maybeTrade.onTick(ticks(i)); i += 1 } maybeTrade } println(s"Option field: ${optionBytes.toDouble / tickCount} bytes per tick") println(s"Maybe field: ${maybeBytes.toDouble / tickCount} bytes per tick") ``` On this machine, writing `Some(tick)` into an `Option[Tick]` field costs 16 bytes per tick, and writing `Maybe(tick)` into a `Maybe[Tick]` field costs nothing. Absence is the `Absent` singleton, not `null`: `Maybe.absent` returns it, and `Maybe.apply` turns a `null` argument into it. `Maybe.present(null)` instead returns a `Present(null)` wrapper, the one case besides nested `Maybe`s where a flat value allocates. Three details decide whether `Maybe` saves anything: - Read it with `Maybe#fold`, `Maybe#getOrElse`, `Maybe#isPresent` and `Maybe#get`, or `Maybe#foreach`. On Scala 3, `case Present(t)` calls `Present.unapply`, which returns an `Option` and so allocates a `Some` per match unless the JIT removes it. - `Maybe[Long]`, `Maybe[Double]` and other primitive types still box the value, because `Maybe[A]` erases to `Object`. The companion example measures 24 bytes per tick for a `Maybe[Long]` price field against 40 for `Option[Long]`. For a primitive, keep the value in a primitive field, or keep the object it belongs to in a `Maybe`, as `LastTrade` does. - On Scala 2.13, `Maybe` is a sealed trait, and `Maybe.present` allocates a `MaybeValue.Present` for every present value, so it saves nothing over `Option` there. The [Maybe reference](../reference/maybe.md) covers the full API.
Runnable version, with the Maybe[Long] price-field measurement: Step6MaybeExample.scala ```scala title="schema-examples/src/main/scala/avoidallocation/Step6MaybeExample.scala" showLineNumbers //> using scala 3.9.0 //> using dep dev.zio::zio-blocks-maybe:0.0.56 //> using options -experimental //> using file Allocation.scala // Run from this folder: scala-cli run Step6MaybeExample.scala // Or from schema-examples: sbt "runMain avoidallocation.Step6MaybeExample" package avoidallocation /** * How to Avoid Allocation on Hot Paths — Replace Option With Maybe for Per-Tick * State * * Writes the last trade of every tick into an `Option` field and a `Maybe` * field, and shows that a `Maybe[Long]` still boxes its value. */ object Step6MaybeExample { final case class Tick(symbol: String, priceMicros: Long, size: Int) val tickCount = 10000 val prices: Array[Long] = Array.tabulate(tickCount)(i => 187000000L + (i % 997) * 1300L) val sizes: Array[Int] = Array.tabulate(tickCount)(i => 100 + i % 50) val ticks: Array[Tick] = Array.tabulate(tickCount)(i => Tick("ACME", prices(i), sizes(i))) final class LastTrade { var tick: Maybe[Tick] = Maybe.absent def onTick(t: Tick): Unit = tick = Maybe(t) def lastPriceMicros: Long = tick.fold(0L)(_.priceMicros) } final class OptionLastTrade { var tick: Option[Tick] = None } // The primitive case: the price itself in each kind of field final class OptionLastPrice { var price: Option[Long] = None } final class MaybeLastPrice { var price: Maybe[Long] = Maybe.absent } def main(args: Array[String]): Unit = { val lastTrade = new LastTrade ticks.foreach(lastTrade.onTick) println(s"last price: ${lastTrade.lastPriceMicros}") val optionTrade = new OptionLastTrade val maybeTrade = new LastTrade val optionBytes = Allocation.bytesPerRun { var i = 0 while (i < tickCount) { optionTrade.tick = Some(ticks(i)); i += 1 } optionTrade } val maybeBytes = Allocation.bytesPerRun { var i = 0 while (i < tickCount) { maybeTrade.onTick(ticks(i)); i += 1 } maybeTrade } println(s"Option field: ${optionBytes.toDouble / tickCount} bytes per tick") println(s"Maybe field: ${maybeBytes.toDouble / tickCount} bytes per tick") val optionPrice = new OptionLastPrice val maybePrice = new MaybeLastPrice val optionPriceBytes = Allocation.bytesPerRun { var i = 0 while (i < tickCount) { optionPrice.price = Some(prices(i)); i += 1 } optionPrice } val maybePriceBytes = Allocation.bytesPerRun { var i = 0 while (i < tickCount) { maybePrice.price = Maybe(prices(i)); i += 1 } maybePrice } println(s"Option[Long] field: ${optionPriceBytes.toDouble / tickCount} bytes per tick") println(s"Maybe[Long] field: ${maybePriceBytes.toDouble / tickCount} bytes per tick") } } ```
## Pre-Bind Metric Labels Outside the Loop Counting ticks per symbol and venue with a telemetry `Counter` is the natural way to export the feed's rate. Passing the labels on every call makes the counter build an `Attributes` value and look it up in its table each time: ```scala val meter = MeterProvider.builder.build().get("feed") val ticksCounter = meter.counterBuilder("ticks").build() ``` When the label values are known before the loop, `Counter#bind` builds the `Attributes` once and returns a `BoundCounter` that adds straight to the counter's accumulator for those labels: ```scala val acmeOnXnas: BoundCounter = ticksCounter.bind(Attributes.builder.put("symbol", "ACME").put("venue", "XNAS").build) ``` A call with label pairs and a call on the bound counter write to the same data point, which `Counter#collect` reads back: ```scala ticksCounter.add(1L, "symbol" -> "ACME", "venue" -> "XNAS") acmeOnXnas.add(1L) ticksCounter.collect() match { case MetricData.SumData(points) => points.map(_.value) case _ => Nil } // res23: List[Long] = List(2L) ``` The difference is what each call allocates. We count every tick of the batch both ways: ```scala val pairBytes = Allocation.bytesPerRun { var i = 0 while (i < tickCount) { ticksCounter.add(1L, "symbol" -> "ACME", "venue" -> "XNAS") i += 1 } } val boundBytes = Allocation.bytesPerRun { var i = 0 while (i < tickCount) { acmeOnXnas.add(1L) i += 1 } } println(s"labels per call: ${pairBytes.toDouble / tickCount} bytes per tick") println(s"bound counter: ${boundBytes.toDouble / tickCount} bytes per tick") ``` On this machine the label pairs cost about 232 bytes per tick, and the bound counter 0. The same holds for `Histogram#bind` and `Gauge#bind`, and for the labeled instruments. `Meter#labeledCounter` declares the label names once, so each call passes only the values, but `LabeledCounter#add` still builds a fresh `Attributes` on every call. On a hot path, call `LabeledCounter#bind` once and keep the handle: ```scala val labeledTicks = meter.labeledCounter("ticks.labeled", "symbol", "venue") val acmeHandle = labeledTicks.bind("ACME", "XNAS") ``` Measured the same way, `LabeledCounter#add` costs about 400 bytes per tick on this machine, more than the label pairs, and the handle costs nothing: ```scala val labeledBytes = Allocation.bytesPerRun { var i = 0 while (i < tickCount) { labeledTicks.add(1L, "ACME", "XNAS") i += 1 } } val handleBytes = Allocation.bytesPerRun { var i = 0 while (i < tickCount) { acmeHandle.add(1L) i += 1 } } println(s"LabeledCounter#add: ${labeledBytes.toDouble / tickCount} bytes per tick") println(s"bound handle: ${handleBytes.toDouble / tickCount} bytes per tick") ``` When label values change per tick but come from a small, known set (the symbols the feed carries), bind each combination once at startup and pick the handle in the loop. The [labeled instruments reference](../reference/telemetry/metrics/labeled-instruments.md) describes `Counter#bind`, `Histogram#bind`, `Gauge#bind` and `LabeledCounter#bind`. ## Check the Numbers With JMH `Allocation.bytesPerRun` is enough to compare two versions of a loop, but a JMH benchmark with the GC profiler gives steadier numbers: it warms each benchmark in a fresh JVM and reports `gc.alloc.rate.norm`, the bytes allocated per call. JMH is not on the classpath of the docs or the example builds, so this benchmark runs with scala-cli. JMH rejects classes in the default package, hence the `package` line. Save it as `HotPathBench.scala`: ```scala //> using scala 3.9.0 //> using options -experimental //> using dep dev.zio::zio-blocks-next-streams:0.0.56 //> using dep dev.zio::zio-blocks-next-maybe:0.0.56 //> using dep dev.zio::zio-blocks-next-telemetry:0.0.56 package hotpath final case class Tick(symbol: String, priceMicros: Long, size: Int) final class OptionState { var lastTick: Option[Tick] = None } final class MaybeState { var lastTick: Maybe[Tick] = Maybe.absent } @State(Scope.Thread) class HotPathBench { val n = 10000 val prices: Array[Long] = Array.tabulate(n)(i => 187000000L + (i % 997) * 1300L) val sizes: Array[Int] = Array.tabulate(n)(i => 100 + i % 50) val ticks: Array[Tick] = Array.tabulate(n)(i => Tick("ACME", prices(i), sizes(i))) val chunk: Chunk[Long] = Chunk.fromArray(prices) val optionState = new OptionState val maybeState = new MaybeState val ticksCounter = MeterProvider.builder.build().get("feed").counterBuilder("ticks").build() val acmeOnXnas = ticksCounter.bind(Attributes.builder.put("symbol", "ACME").put("venue", "XNAS").build) // Streams @Benchmark def iteratorPipeline(): Long = prices.iterator.map(_ / 10000L).filter(_ > 18750L).foldLeft(0L)(_ + _) @Benchmark def primitiveStream(): Long = Stream.fromArray(prices).map(_ / 10000L).filter(_ > 18750L).runFold(0L)(_ + _).getOrElse(0L) @Benchmark def zippedStream(): Long = (Stream.fromArray(prices) && Stream.fromArray(sizes)).map { case (p, s) => p * s }.runFold(0L)(_ + _).getOrElse(0L) @Benchmark def indexedStream(): Long = Stream.range(0, n).map(i => prices(i) * sizes(i)).runFold(0L)(_ + _).getOrElse(0L) // Chunk @Benchmark def chunkCopy(): Chunk[Long] = Chunk.from(prices) @Benchmark def chunkWrap(): Chunk[Long] = Chunk.fromArray(prices) @Benchmark def chunkFoldLeft(): Long = chunk.foldLeft(0L)(_ + _) @Benchmark def chunkIndexLoop(): Long = { var sum = 0L var i = 0 while (i < chunk.length) { sum += chunk.long(i); i += 1 } sum } @Benchmark def genericBuilder(): Chunk[Long] = { val builder = Chunk.newBuilder[Long] var i = 0 while (i < n) { builder.addOne(prices(i)); i += 1 } builder.result() } @Benchmark def longBuilder(): Chunk[Long] = { val builder = new ChunkBuilder.Long builder.sizeHint(n) var i = 0 while (i < n) { builder.addOne(prices(i)); i += 1 } builder.result() } // Maybe @Benchmark def optionField(): OptionState = { var i = 0 while (i < n) { optionState.lastTick = Some(ticks(i)); i += 1 } optionState } @Benchmark def maybeField(): MaybeState = { var i = 0 while (i < n) { maybeState.lastTick = Maybe(ticks(i)); i += 1 } maybeState } // Telemetry @Benchmark def labelsPerCall(): Unit = { var i = 0 while (i < n) { ticksCounter.add(1L, "symbol" -> "ACME", "venue" -> "XNAS"); i += 1 } } @Benchmark def boundCounter(): Unit = { var i = 0 while (i < n) { acmeOnXnas.add(1L); i += 1 } } } ``` Run it with the GC profiler (`-prof gc`); the short warm-up and measurement settings keep the run to a few minutes: ```bash scala-cli --power run --jmh HotPathBench.scala -- -wi 3 -i 3 -f 1 -w 2s -r 2s -prof gc ``` Each benchmark handles 10,000 ticks per call, so dividing `gc.alloc.rate.norm` by 10,000 gives bytes per tick. On this machine, with JDK 25, the results were roughly: | Hot-path code | Before (bytes per tick) | After (bytes per tick) | | ---------------------------------------------------- | ----------------------- | -------------------------- | | `Iterator` pipeline → `Stream` on the `Long` lane | 62 | 0.07 | | `&&` zip → `Stream.range` index | 55 | 0.01 | | `Chunk.from` copy → `Chunk.fromArray` wrap | 8 | 0.003 (32 bytes per batch) | | `Chunk#foldLeft` → `Chunk#long` index loop | 24 | 0 | | `Chunk.newBuilder[Long]` → sized `ChunkBuilder.Long` | 58 | 16 | | `Option` field → `Maybe` field | 16 | 0 | | Label pairs per call → `BoundCounter` | 232 | 0 | Throughput moves with allocation but varies more between runs, so compare `gc.alloc.rate.norm` first. A benchmark is the only way to know: the JIT removes some allocations on its own (the plain `chunk(i)` loop above) and keeps others that look free, so measure before and after every change. [How to Tune JSON Encoding and Decoding for Throughput](./tune-json-throughput.md) shows how to run the repository's own benchmarks. ## Running the Examples The runnable code from this guide lives in three example builds, one per set of modules: - [`streams-examples/src/main/scala/avoidallocation/`](https://github.com/zio/zio-blocks/tree/main/streams-examples/src/main/scala/avoidallocation): the baseline handler, streams and `Chunk` - [`schema-examples/src/main/scala/avoidallocation/`](https://github.com/zio/zio-blocks/tree/main/schema-examples/src/main/scala/avoidallocation): `Maybe` - [`telemetry-examples/src/main/scala/avoidallocation/`](https://github.com/zio/zio-blocks/tree/main/telemetry-examples/src/main/scala/avoidallocation): bound counters Run any file from its folder with `scala-cli run .scala`, or from the build's directory with `sbt "runMain avoidallocation."`. Each example prints the bytes it allocates per tick on your machine. ## Going Further - [Zero-Boxing Optimization](../reference/streams/execution-and-compatibility/zero-boxing.md) explains how lanes work inside the stream interpreter. - The [Reader reference](../reference/streams/primitives/reader.md) lists every primitive read and its end-of-stream signal. - The [Chunk reference](../reference/chunk.md) covers the other `Chunk` constructors and builders. - [Instrument a Program with Logs, Traces and Metrics](./instrument-a-program-with-logs-traces-and-metrics.md) shows where metrics fit in a whole program. --- ## Build a Live Counter and Dashboard with Datastar Welcome! This tutorial is for Scala developers who can already build an HTML page with the ZIO Blocks HTML module and now want that page to react to clicks and to server updates without writing any JavaScript. You don't need prior experience with Datastar. If you haven't built a page with the HTML module yet, start with [Render Your First Server-Side HTML Page](./your-first-html-page.md); this tutorial picks up where it ends. ## 1. Introduction By the end of this tutorial, you will understand: - What a Datastar signal is, and how `dataSignals` and `dataText` put one on a page - How a `dataOn.click` attribute makes the browser send the signals to the server, and how the server answers with a `DatastarEvent.patchSignals` event - How to read the signals the browser sends into a case class with the schema JSON codec - How a `DatastarEvent.patchElements` event swaps a piece of HTML into the page - How one long-lived event stream turns a table into a live dashboard, and what the server sees when the browser goes away We learn these concepts by building a small weather station, one step at a time: 1. A page with a signal 2. A button that posts 3. Reading the signals 4. Swapping a fragment 5. A live dashboard At the end, we run the finished page and try it in a browser. We recommend reading from top to bottom — each section builds on the previous one. The datastar module is for Scala 3 only: there is no Scala 2.13 artifact. Add it to your `build.sbt`: ```scala // Scala 3.3 to 3.8 libraryDependencies += "dev.zio" %% "zio-blocks-datastar" % "0.0.56" // Scala 3.9 and newer (also needs scalacOptions += "-experimental") libraryDependencies += "dev.zio" %% "zio-blocks-next-datastar" % "0.0.56" ``` The datastar module depends on the HTML module and the http-model module. The http-model module is compiled for Java 21, so the programs in this tutorial need JDK 21 or newer to run. The module's package is `zio.http.datastar`, even though the artifact is called `zio-blocks-datastar`. The module gives you the `data-*` attributes and the events. It does not ship a server and it does not ship the Datastar JavaScript, so this tutorial serves its pages with the streaming adapter from [How to Send Server-Sent Events with http-model](./send-server-sent-events.md), a small piece of code that connects the JDK's built-in HTTP server to a `Request => Response` function. We reuse it and don't explain it again. ## 2. Background: Signals in the Browser, Patches from the Server Datastar is a small JavaScript library that reads `data-*` attributes on ordinary HTML elements. The page keeps named values called *signals*. An attribute such as `data-text="$readings"` shows a signal, and an attribute such as `data-on:click="@post('/reading')"` says what to do on a click: send a `POST` request to the server. Every request carries the signals. A `POST` sends them as a JSON body, and a `GET` sends the same JSON in a query parameter called `datastar`. The server answers with a `text/event-stream` response, a stream of Server-Sent Events (SSE), where each event is a *patch*: a `datastar-patch-signals` event sets new signal values, and a `datastar-patch-elements` event swaps new HTML into the page. The exchange for one click looks like this: ```text Browser Server ─────── ────── Sencha $12.00 ``` The response is the `#catalog` element alone, with the two green teas. The same URL without `HX-Request` returns `` and the whole layout around the same element. In a browser, clicking "Green" sends `HX-Request: true` and swaps the new `#catalog` in, and the address bar shows `/?category=Green`. A reload of that address gets the full page. Clicking the boosted "Cart" link sends `HX-Request: true` and `HX-Boosted: true`, gets the full cart page and keeps the header, and the tab title changes to "Cart - Tea Shop". With htmx's history cache turned off (`htmx.config.historyCacheSize = 0`), pressing Back sends `HX-History-Restore-Request: true` and gets the full page back. :::warning A check that reads only `HX-Request` answers the last two rows with a fragment. We tried that version in the browser: after a click on the boosted "Cart" link, the header and its navigation were gone and the tab title still said "Teas", and after Back with a history cache miss, the `` held only the product list. The tutorial's check, which reads `HX-Request` and `HX-History-Restore-Request`, is enough for a page without boosted links; once the page uses `hxBoost`, read `HX-Boosted` too. ::: ## Update a Second Spot with an Out-of-Band Swap When the reader clicks "Add to cart", the button replaces itself with "In cart (1)". The cart badge in the header must change too, but it is outside the button's target. An out-of-band swap updates it in the same response: htmx looks for elements with an `hx-swap-oob` attribute in the response, takes them out, and swaps each one into the page element with the same `id`. The HTMX module types this attribute as `hxSwapOob`: ```scala def addToCart(productId: String): Response = products.find(_.id == productId) match { case None => Response.text(Status.NotFound, s"No product $productId") case Some(product) => cart.add(product.id) val badge = cartBadge(hxSwapOob := HxSwapOob.True) htmlResponse(addButton(product).render + badge.render) } ``` The response is the new button, which htmx swaps into the button's own place, followed by the badge. `cartBadge(hxSwapOob := HxSwapOob.True)` adds the attribute to the same element the layout renders, so the badge's markup lives in one place. The two elements are rendered separately and concatenated: a fragment can contain any number of top-level elements. Add a Sencha to the cart: ```scala show(addToCart("sencha")) // status: 200 // 1 ``` In a browser, the button changes to "In cart (1)" and the header's badge changes from 0 to 1 after one request. htmx removes `hx-swap-oob` from the element before it inserts it, so the page keeps a plain ``. `HxSwapOob.True` replaces the whole element with the same `id`. `HxSwapOob.using(HxSwap.InnerHTML)` swaps only its content, and `HxSwapOob.using(HxSwap.InnerHTML, HxTarget.css("#cart-count"))` names the target explicitly, so the element in the response can have another `id` or none. :::warning Keep the `HxSwap` you pass to `HxSwapOob.using` bare, without modifiers such as `swap(...)` or `settle(...)`. In 0.0.56, `HxSwapOob.using(HxSwap.InnerHTML.swap(10.millis))` renders `hx-swap-oob="innerHTML swap:10ms"`, which htmx 2 reads as the strategy `innerHTML swap` and the selector `10ms`. In the browser this raised `htmx:swapError`, and htmx swapped nothing from that response, not even the main fragment. ::: ## Tell the Page Something Happened An out-of-band swap works when the server knows each spot to update. When several parts of the page care about the cart, it is simpler to announce what happened and let each part decide. The `HX-Trigger` response header names client-side events that htmx fires as soon as the response arrives. Its typed form is `HxTriggerHeader`, and its value is an `HxEventPayload`: ```scala def addToCartAndAnnounce(productId: String): Response = { val response = addToCart(productId) products.find(_.id == productId).fold(response) { product => val message = Json.Object("show-message" -> Json.Object("text" -> Json.String(s"Added ${product.name}"))) response .addHeader(HxTriggerHeader(HxEventPayload.Event("cart-updated"))) .addHeader(HxTriggerAfterSettle(HxEventPayload.JsonValue(message))) } } def cartSummaryFragment: Response = htmlResponse(cartSummary.render) ``` The code above sends two events with two kinds of payload: - `HxEventPayload.Event("cart-updated")` is a plain event name. The `cartSummary` element from the layout listens for it with `hxTrigger := HxTrigger("cart-updated").from("body")`, which renders as `hx-trigger="cart-updated from:body"`: htmx fires the event on the button that sent the request, the event bubbles up to ``, and the summary sends `GET /cart/summary`. `cartSummaryFragment` answers with a new `