Laminar: UI Without the Virtual DOM
Most mainstream frontend frameworks are built on the same bet: describing the entire UI from scratch on every state change is a nice programming model, and we can make it fast enough with a virtual DOM and a diffing algorithm. React made that bet in 2013 and the whole industry followed.
Laminar, the Scala.js UI library I’ve been using for a couple of months now, refuses the bet entirely. No virtual DOM, no re-renders, no diffing. In my previous note I covered the two core types, Var and Signal. This article is about how the whole model fits together, and what it’s like to actually build with it.
The Model: Wire Once, Update Forever
A Laminar “component” is a plain function that returns a DOM element:
1def entryCard(entry: Signal[Entry]): HtmlElement =
2 div(
3 cls := "card",
4 h2(child.text <-- entry.map(_.title)),
5 p(cls := "tags", child.text <-- entry.map(_.tags.mkString(", ")))
6 )
This function runs once. The <-- bindings register subscriptions: when entry emits a new value, Laminar writes the new title into that exact text node. Nothing else executes. There is no reconciliation because there is nothing to reconcile, the dependency graph from state to DOM node was built explicitly, so updates are just graph edges firing.
The performance conversation changes completely. In React you eventually meet memo, useMemo, useCallback, tools to avoid work the model creates by default. Laminar’s default is already the minimal work. I’ve stopped thinking about render performance at all, which is a strange feeling.
Events Flow the Other Way
The --> arrow is the mirror image: DOM events flowing into your state.
1def searchBox(query: Var[String]): HtmlElement =
2 input(
3 typ := "text",
4 placeholder := "Search…",
5 value <-- query.signal,
6 onInput.mapToValue --> query.writer
7 )
Read those two arrows together and you get a two-way binding, except both directions are explicit. Events are streams, so the usual stream operators are right there, this debounced live search took one line more than the naive version:
1onInput.mapToValue
2 .compose(_.debounce(300)) --> (q => searchFor(q))
Coming from the HTMX world (this blog does its live search with hx-trigger="keyup changed delay:300ms"), it’s fun to see the exact same idea, declarative event plumbing on the element itself, appear in a completely different stack.
Lists Without Keys (Sort Of)
The classic virtual DOM pain point is list rendering, with the ritual key prop. Laminar’s answer is split:
1def entryList(entries: Signal[List[Entry]]): HtmlElement =
2 ul(
3 children <-- entries.split(_.id) { (id, initial, entrySignal) =>
4 li(entryCard(entrySignal))
5 }
6 )
split takes the list signal apart by key. For each distinct id, the render function is called once, and receives a Signal[Entry] that tracks just that element’s changes over time. When the list updates, existing DOM nodes are reused and moved; only genuinely new ids get rendered. It’s the same problem key solves in React, but the API makes it impossible to forget, and the per-item signal means an item’s card updates without the list even knowing.
Ownership: The Part That Makes It Safe
Every <-- creates a subscription, and subscriptions in long-lived FRP systems are how you get memory leaks. Laminar’s answer is its ownership system: every subscription must have an owner, and for bindings created inside an element, the owner is the element itself.
The lifecycle rule is beautifully simple:
- Element gets mounted into the DOM → its subscriptions activate.
- Element gets unmounted → its subscriptions deactivate.
Remove a component from the page and every listener it created is cleaned up, transitively, automatically. In two months I haven’t written a single manual unsubscribe. This is the part of Laminar I want to dig into properly, it’s the load-bearing wall of the whole design and it deserves its own note.
Where It Fits in My Stack
This is the frontend of the full-stack setup from my shared DTOs article. The combination is the point:
1val entries: Var[List[Entry]] = Var(Nil)
2
3def load(): Unit =
4 fetchEntries().foreach(entries.set) // Future[List[Entry]], typed end to end
5
6def view: HtmlElement =
7 div(
8 button("Reload", onClick --> { _ => load() }),
9 entryList(entries.signal)
10 )
Entry is the same case class the backend serializes. The fetch is typed, the state is typed, the bindings are typed. When I changed the Entry model last week, the compiler walked me through every place the UI touched it, backend, frontend, no exceptions at runtime, literally.
The Honest Downsides
- The ecosystem is small. There is no Laminar component library remotely like MUI or shadcn. You write your own components, on top of plain CSS or Tailwind. For me (Tailwind, custom design anyway) this costs little; for a big team it might cost a lot.
- Scala.js tooling is a separate world. The compile-link-reload loop is good but different from the JS mainstream, and integrating npm libraries takes facades or interop work.
- Hiring/onboarding. Realistically, this stack is for teams already sold on Scala. I’m one person, so the constraint doesn’t bind.
Verdict
Laminar is the first frontend library where my mental model and the library’s actual behaviour are the same thing: state is a graph of signals, the DOM hangs off its edges, events feed back in. Nothing is hidden behind a scheduler or a diffing heuristic.
React asks: how do we make re-describing everything cheap? Laminar asks: what if we just… didn’t? After two months, I’m increasingly convinced the second question was the right one all along, at least when you have a type system like Scala’s to keep the wiring honest.
Thanks for reading.