The Apothecary

A hands-on Laminar lab

Jamie Thompson · ScalaDays 2026

About this lab

Focus: Laminar, a reactive UI library fo Scala.js

Concepts covered

  • reactive state
  • wiring forms to backend
  • controlled inputs with validation,
  • async event handling.

Exercises

  • a cauldron mixer
  • a potion registration form
  • an ingredient search & inventory
  • Custom widgets for a reactive Scala notebook

Agenda

TimeModule
0:101 · Reactive basics
0:302 · Forms & validation
0:553 · Async & the backend
1:154 · Organisation & interop
1:25Capstone · notebook.sc widgets
1:55Wrap-up

Setup, step by step

exercises/00-setup

  1. Install Scala CLI and Node.js
  2. git clone --recurse-submodules git@github.com:bishabosha/scaladays-2026-laminar-lab.git then cd scaladays-2026-laminar-lab
  3. ./sake check (must succeed)
  4. In a second terminal: ./sake prepare, and leave it running
  5. ./sake ex 00-setup, open http://127.0.0.1:8000/?ex=00-setup

Full instructions: github.com/bishabosha/scaladays-2026-laminar-lab · Windows: sake instead of ./sake

How the exercises work

Pick one to work on./sake ex 01-cauldron.
Next, edit exercises/01-cauldron/start/,
the page reloads on edit
Run the solution./sake ex-solution 01-cauldron
Reset if needed./sake reset-ex 01-cauldron
List every command./sake

Fake potion store

As the new apprentice your task is to build lots of the machinery for automating the potion store (apothecary).

  • a cauldron colour mixer
  • a recipe registration form with validation rules
  • ingredient search and stock management
  • a simulated backend with latency and failures

demo: potions/mixer

1 · Reactive basics

Var, Signal, EventStream and ownership

Var, Signal, EventStream

val heat: Var[Int] = Var(40)

val colour: Signal[String] =
  heat.signal.map(h =>
    if h > 80 then "crimson" else "violet")

val bubbles: EventStream[Unit] =
  EventStream.periodic(500).mapToUnit
  • Var: mutable state, set / update
  • Signal: always has a current value
  • EventStream: things that happen
  • map, combineWith, changes, startWith

Binding with <-- and -->

def cauldron(heat: Var[Int]): HtmlElement =
  div(
    cls := "cauldron",
    backgroundColor <-- heat.signal.map(colourFor), // data → DOM
    input(
      typ := "range", minAttr := "0", maxAttr := "100",
      controlled(
        value <-- heat.signal.map(_.toString),
        onInput.mapToValue.map(_.toInt) --> heat     // DOM → data
      )
    ),
    child.text <-- heat.signal.map(h => s"$h°"),
    button("Stir", onClick --> (_ => heat.update(_ + 5)))
  )

<-- reads into the element · --> writes out of it

Subscription ownership

  • Every <-- / --> is a subscription owned by its element
  • Mounted → active · unmounted → killed. No leaks, no dispose()
  • Laziness: an unobserved signal does no work
div(
  EventStream.periodic(1000) --> (_ => println("tick")), // stops on unmount
  onMountCallback(ctx => println("mounted"))
)

Exercise 1 · The cauldron

exercises/01-cauldron

  1. Three sliders: moonwater, nightshade, fire salt
  2. The cauldron's colour mixes from all three
  3. Show "It's going to blow!" when fire salt > 80
  4. ★ A Stir button that adds a little of everything

./sake ex 01-cauldron

demo: 01-cauldron/app

2 · Forms & validation

Controlled inputs, validation and error display

Controlled inputs

case class Potion(name: String, doses: Int, drinkable: Boolean, explosive: Boolean)

val potion = Var(Potion("", 1, drinkable = true, explosive = false))

input(
  controlled(
    value <-- potion.signal.map(_.name),
    onInput.mapToValue --> potion.updater[String]((p, n) => p.copy(name = n))
  )
)
  • controlled makes the Var the single source of truth
  • Filter input before it lands: onInput.mapToValue.filter(_.forall(_.isDigit))

Lenses over a case-class Var

Simplified updaters for large form-like data (product types).

val form = VarLenses(Potion("", 1, drinkable = true, explosive = false))

input(controlled(
  value <-- form.name.view,
  onInput.mapToValue --> form.name.updater
))
  • form.name is a named tuple with two fields: updater: Observer[String] and view: Signal[String].
  • Compatible with any case class that supports scala.deriving.Mirror.

Validation is just a Signal

case class Problem(field: String, message: String)

def validate(p: Potion): List[Problem] =
  List(
    Option.when(p.name.isBlank)(Problem("name", "Every potion needs a name")),
    Option.when(p.doses > 12)(Problem("doses", "At most 12 doses per batch")),
    Option.when(p.drinkable && p.explosive)(
      Problem("explosive", "A potion may not be both drinkable and explosive"))
  ).flatten

val problems: Signal[List[Problem]] = potion.signal.map(validate)

A pure function: test it without a browser

Errors after blur or submit

val touched = Var(Set.empty[String])
val submitted = Var(false)

def errorsFor(field: String): Signal[List[Problem]] =
  problems
    .combineWith(touched.signal, submitted.signal)
    .map((ps, t, s) => if s || t(field) then ps.filter(_.field == field) else Nil)

input(onBlur --> (_ => touched.update(_ + "name")), /* ... */)

form(onSubmit.preventDefault --> { _ =>
  submitted.set(true)
  if validate(potion.now()).isEmpty then save(potion.now())
})

Exercise 2 · Register a potion

exercises/02-potion-form

  1. Name, doses, ingredients, two checkboxes
  2. Errors appear after blur or submit
  3. Not both drinkable and explosive
  4. Dragon's breath needs a licence no.
  5. ★ A problem summary

Done when ./sake test 02-potion-form passes

demo: 02-potion-form/app

3 · Async & the backend

A slow, unreliable backend

The backend: Oracle

trait Oracle:
  def searchIngredients(query: String): Future[List[Ingredient]]
  def inventory(): Future[List[Stock]]
  def save(stock: Stock): Future[Stock]
  def delete(id: StockId): Future[Unit]
  def register(potion: Potion): Future[String]
  • Runs in the browser, with random latency (100–1500 ms)
  • 10 % of calls fail
  • Latency and failure rate are adjustable in each exercise page's oracle panel

Stale responses

val query = Var("")

val results: EventStream[List[Ingredient]] =
  query.signal.changes
    .debounce(300)                                       // wait for a pause
    .flatMapSwitch(q =>                                  // drop stale answers
      EventStream.fromFuture(oracle.searchIngredients(q)))
  • flatMapMerge shows whichever response arrives last
  • flatMapSwitch only emits the response to the latest request

Modelling remote state

enum Remote[+A]:
  case Loading
  case Ready(value: A)
  case Failed(reason: String)

def consult[A](f: => Future[A]): Signal[Remote[A]] =
  EventStream
    .fromFuture(f.map(Remote.Ready(_)).recover(e => Remote.Failed(e.getMessage)))
    .startWith(Remote.Loading)

child <-- consult(oracle.inventory()).map {
  case Remote.Loading       => p(cls := "loading", "Loading…")
  case Remote.Ready(items)  => ul(items.map(i => li(i.name)))
  case Remote.Failed(why)   => p(cls := "error", why, button("Retry", onClick --> retry))
}

Keyed lists with split

val stock: Var[List[Stock]] = Var(Nil)

ul(
  children <-- stock.signal.split(_.id) { (id, initial, item: Signal[Stock]) =>
    li(
      child.text <-- item.map(_.name),
      child.text <-- item.map(s => s" × ${s.quantity}"),
      button("−", onClick --> (_ => adjust(id, -1)))
    )
  }
)
  • One element per id, created once, then fed an updated Signal
  • Focus, scroll and CSS transitions survive updates

Exercise 3 · The cellar

exercises/03-cellar

  1. Ingredient search: debounce + flatMapSwitch
  2. Loading / failed / ready, with retry
  3. Shelves with split, plus delete
  4. Optimistic: change now, undo on refusal
  5. ★ Dim just the row being saved

demo: 03-cellar/app

4 · Organisation & interop

Components, errors and JavaScript interop

A component is a function

object DoseInput:
  def apply(value: Signal[Int], onChange: Observer[Int], max: Int = 12): HtmlElement =
    div(
      cls := "dose-input",
      button("−", onClick(_.sample(value).map(v => (v - 1) max 1)) --> onChange),
      child.text <-- value,
      button("+", onClick(_.sample(value).map(v => (v + 1) min max)) --> onChange)
    )
  • In: Signals. Out: Observers. Return: an element
  • State lives where it is needed, usually in the page, not the component
  • To expose more, return a small class: .element plus extra signals

Error handling

  • Model failures as values (Remote.Failed, Problem) and render them
  • One EventBus[Notice] per page for notifications
  • An exception thrown in an observer is logged and otherwise ignored; register a handler
AirstreamError.registerUnhandledErrorCallback(e => notices.emit(Notice.Bug(e)))

JavaScript interop

object Confetti:
  @js.native @JSImport("https://esm.sh/canvas-confetti@1.9.3", JSImport.Default)
  def fire(options: js.Object): js.Promise[Unit] = js.native

button(
  "Brew!",
  onClick --> (_ => Confetti.fire(js.Dynamic.literal(particleCount = 150, spread = 70)))
)

Libraries that own DOM nodes: create on mount, destroy on unmount

div(onMountUnmountCallback(
  mount = ctx => chart = Chart(ctx.thisNode.ref, config),
  unmount = _ => chart.destroy()
))

Exercise 4 · Components and interop

exercises/04-brew

  1. Extract DoseInput and use it in your potion form
  2. Write a facade for canvas-confetti and fire it on a successful save
  3. ★ Explosive potions get red confetti and a screen shake

Capstone · notebook.sc widgets

Laminar widgets hosted in another application

notebook.sc

  • Scala cells form a dependency graph: run one, its dependents re-run
  • The UI is written in Laminar
  • Built-in widgets: slider, select, text, form
  • A widget's value becomes a cell: val heat: Int = 42
// cell: a slider widget
val heatH =
  Widgets.slider("heat", 0, 100, 40)

// cell: re-runs when heat changes
if heat > 80 then "high" else "normal"

The plugin contract

case class Rgb(r: Int, g: Int, b: Int) derives Json.ReadWriter   // upickle

object Main extends LaminarPlugin:                                // notebook-plugin-api
  def view(payload: Signal[String], ctx: PluginContext): HtmlElement =
    val widget = ctx.widgetOpt.get.as[Rgb](Rgb(0, 0, 0))          // value, set, signal
    val colour = Var(widget.value)
    div(widget.signal --> colour, sliders(colour, onChange = widget.writer))
// a notebook cell
case class Rgb(r: Int, g: Int, b: Int)
  derives ConfiguredJsonValueCodec
val mixH = Widgets.custom[Rgb](
  "mix", "potions/mixer",
  config = "{}", Rgb(180, 40, 120))
  • Separate bundles: only JSON crosses, a case class each side
  • An unknown plugin renders as a fallback
  • The same bundle drives this deck's demos

Capstone options

WidgetStart inModule
Mixer: sliders → an Rgbnew-widget1
Recipe: a form → a Potionwidgets/recipe2
Oracle: search → List[String]widgets/oracle3
Chart: plots a cell over timewidgets/chart4

./sake widget recipe WATCH=1 · ./sake notebook

Capstone

  • 20 minutes, alone or in pairs
  • A few notebooks are demonstrated at the end
  • Extension: connect two widgets through cells

demo: potions/mixer

Summary

  • Var / Signal / EventStream, and two arrows
  • Ownership: subscriptions die with their elements
  • Validation as a pure function → Signal
  • debounce + flatMapSwitch for async
  • split for targeted updates
  • Components: signals in, observers out
  • Errors as values, rendered
  • JS interop with facades and mount hooks
  • Plugins: Laminar inside another app

Thank you

laminar.dev · github.com/bishabosha/scaladays-2026-laminar-lab

Appendix · An HTTP backend

class HttpOracle(base: String) extends Oracle:
  def searchIngredients(query: String) =
    dom.fetch(s"$base/ingredients?q=${js.URIUtils.encodeURIComponent(query)}").toFuture
      .flatMap(_.text().toFuture)
      .map(read[List[Ingredient]](_))

The exercises depend only on Oracle, so this replaces the simulated backend without changes to the views

Appendix · Cheat sheet

I want to…Laminar
show a valuetext <-- signal
show a maybe-elementchild.maybe <-- signal
show a list, keyedchildren <-- signal.split(_.id)(…)
react to an eventonClick --> observer
read state in a handleronClick(_.sample(signal)) --> …
run on mount / unmountonMountUnmountCallback(…)
drop stale async resultsflatMapSwitch