Focus: Laminar, a reactive UI library fo Scala.js
Concepts covered
Exercises
exercises/00-setup
git clone --recurse-submodules git@github.com:bishabosha/scaladays-2026-laminar-lab.git then cd scaladays-2026-laminar-lab./sake check (must succeed)./sake prepare, and leave it running./sake ex 00-setup, open http://127.0.0.1:8000/?ex=00-setupFull instructions: github.com/bishabosha/scaladays-2026-laminar-lab · Windows: sake instead of ./sake
As the new apprentice your task is to build lots of the machinery for automating the potion store (apothecary).
demo: potions/mixer
Var, Signal, EventStreamval 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 / updateSignal: always has a current valueEventStream: things that happenmap, combineWith, changes, startWith<-- 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
<-- / --> is a subscription owned by its elementdispose()div(
EventStream.periodic(1000) --> (_ => println("tick")), // stops on unmount
onMountCallback(ctx => println("mounted"))
)
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 truthonInput.mapToValue.filter(_.forall(_.isDigit))VarSimplified 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].scala.deriving.Mirror.Signalcase 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
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())
})
Oracletrait 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]
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 lastflatMapSwitch only emits the response to the latest requestenum 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))
}
splitval 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)))
)
}
)
Signalobject 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)
)
Signals. Out: Observers. Return: an element.element plus extra signalsobject 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()
))
slider, select, text, formval 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"
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))
Var / Signal / EventStream, and two arrowsSignaldebounce + flatMapSwitch for asyncsplit for targeted updatesclass 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
| I want to… | Laminar |
|---|---|
| show a value | text <-- signal |
| show a maybe-element | child.maybe <-- signal |
| show a list, keyed | children <-- signal.split(_.id)(…) |
| react to an event | onClick --> observer |
| read state in a handler | onClick(_.sample(signal)) --> … |
| run on mount / unmount | onMountUnmountCallback(…) |
| drop stale async results | flatMapSwitch |