DEV Community

loading...
Cover image for My Four Year Quest For Perfect Scala.js UI Development

My Four Year Quest For Perfect Scala.js UI Development

raquo profile image Nikita Gazarov Updated on ・8 min read

Laminar is my Scala.js UI library that was intended as an alternative to React.js but turned out to be its polar opposite in entirely too many ways as I slowly figured out what perfect UI development is to me, and how to achieve it (spoiler alert: not in that order).

Having just released Laminar v0.8.0 I wanted to reflect for a moment on what this release means for me and hopefully for the wider Scala.js ecosystem, and why I'm doing this in the first place.

Scala.js

Scala.js is an amazing platform to build UI-heavy applications. It is nothing like Typescript or Flow. You're not writing sort-of-typed Javascript, you're writing bona fide Scala, with all the elegant simplicity, safety and expressiveness that it allows and encourages.

That was my realization in 2016 when I first tried Scala.js. What followed soon afterwards was a similarly strong dissatisfaction with the Scala.js UI libraries available at the time. I liked React.js, and technically I could write in React in Scala.js using ScalaJS-React, but I felt that the simplicity of both Scala and React was lost in that union.

Not fully understanding the reason for that at the time, and unwilling to abandon Scala.js, I tried to look for something simpler than React among popular JS libraries. I found André Staltz's Cycle.js, an "honestly functional framework for building web interfaces" I think it was taglined at the time. Cycle's microverse taught me functional reactive programming (FRP), and successfully wrangling Cycle's API into Scala's type system (Cycle.scala) taught me two more things: a) Scala's type system is amazing, and b) I should not be fighting it so hard.

Scala.js is a remarkable achievement in safely and usefully encoding one language's semantics in another (Sébastien's thesis is a great read on this). You can use any Javascript library in a type safe manner, all you need is to describe the API of the library using Scala's type system. Usually this is very easy, much like writing a Typescript definition.

However, even simple-looking Javascript UI libraries tend to exploit the dynamic nature of Javascript quite heavily. For example both React and Cycle.js rely heavily on structural typing which is the norm in Javascript and Typescript, but is absent from Scala.js. As a result, the Scala.js interfaces for such idiomatic Javascript libraries need to be quite thick if they want to be both safe and convenient.

Ultimately I had to concede that it's impossible to write type safe idiomatic Javascript no matter your language of choice. You can either write idiomatic Scala that is type safe and interops with Javascript pretty well, or you can write idiomatic Javascript in Typescript which is very compatible but not nearly safe enough for my taste. But I didn't even want to write idiomatic Javascript. I only needed it because Javascript UI libraries are made that way.

With this revelation fresh in my head, the next step was obvious – take the event streaming approach of Cycle.js that I liked, and build my own native Scala.js UI library to avoid the impedance mismatch with Javascript. Idiomatic Scala all the way. Of course my library would use virtual DOM like both Cycle.js and React do because how else could you possibly implement an efficient DOM manipulation library.

The First Laminar

Right as I figured this out, Outwatch was released as if to my exact specifications: a native Scala.js library based on virtual DOM and FRP with event streams. And it even used Snabbdom, the same virtual DOM library used in Cycle.js. Double win!

I tried Outwatch and... faced the same problem as I had with Cycle.js – even though I understood how the library worked, I couldn't figure out how to use it effectively. I didn't understand how to break out from the example pattern and build real applications with it. Something wasn't clicking for me, again.

At this point I was exhausted and couldn't rationally justify diving deeper into this madness. I'm an Economist, I know a sunk cost when I see it. I have many other interesting things to do. I should have walked away.

And yet, I couldn't admit that I'm only capable of productively working with highly polished mainstream libraries like React. This is no longer a matter of time efficiency. If software is what I do for a living, I need to be better, or accept my limit now. And so, cursing all this esoteric stuff I've gotten myself into, I begrudgingly tried one last thing: I essentially reimplemented Outwatch from scratch, except with xstream.js instead of RxJS for the reactive layer.

If you care to see the result, it's here. It did not make me happy. This first prototype of "Laminar" was very hard to use for the same reason I had trouble with Cycle and Outwatch, and having now walked the path myself I finally understood exactly what that reason was: functional reactive programming (FRP) and virtual DOM don't mix!

Virtual DOM and FRP solve the exact same problem – efficiently keeping the rendered DOM in sync with application state – but they approach it from entirely opposite directions:

FRP is very targeted and precise – your observables tunnel updated data to exactly where it's needed. When you say a(href <-- urlStream, "link"), you directly bind urlStream to update this href attribute on this element. Or... you would, if not for virtual DOM.

Virtual DOM has a completely different idea of how DOM updates should be propagated. Instead of wiring up explicit data propagation paths with observables, you just need to tell virtual DOM when your data – any data – changes, and it will re-render your whole component (with diffing for efficiency, but that's not important conceptually).

Virtual DOM's approach is the opposite of precision. When a component's props or state change, it doesn't know what exact fields changed, and it doesn't know where that data ends up being rendered without performing a costly re-render. On the contrary, the FRP part has the precision to know these things, but it has to discard that knowledge only for virtual DOM to re-derive that same knowledge from scratch by re-rendering your component.

Alternatively, if you try to hold on to that FRP knowledge to perform targeted updates bypassing the virtual DOM, you will run into another problem – your stream-to-element binding logic now needs to account for virtual DOM's lack of stable references. In virtual DOM the virtual elements are ephemeral, being recreated on every re-render, and real DOM nodes can be discarded and replaced at any time. So any time your component updates you need to patch all your bindings on that component to point to the new element if it changed. That's a lot of work, and we haven't even touched on how to tell virtual DOM that you patched this element outside of virtual DOM so that it knows what to diff the next update against.

Either way you go about it, FRP + virtual DOM ends up being less than the sum of its parts.

The First Okay Laminar

I liked the promise of FRP more than I dreaded throwing out all my virtual DOM work, so that's exactly what I did. A surprisingly short amount of focused work later, Laminar v0.1 shipped without any virtual DOM, using my freshly made Scala DOM Builder which kept track of your application's DOM tree much like Virtual DOM would, except it didn't need to do any diffing, and its "virtual" elements were not ephemeral, their lifetime was tied one-to-one to the corresponding real DOM node.

Having stable references to DOM elements allowed Laminar to bind streams to elements directly, improving its internal efficiency and simplicity, but even more importantly trying to build apps in this new Laminar was immensely liberating, euphoric. For the first time in this long endeavor, I felt relief. Using this version was clunky, but not hopeless, and I could feel that I'm on to something worthwhile.

Airstream

However, having finally started using FRP to build more than just toy applications, it soon became apparent to me why it isn't more popular on the frontend.

The basic concept of streams is very simple to grasp for a Javascript developer: "lazy Promises that can emit more than once". And yet building an application on that paradigm is not as easy – or as safe – as expected, largely because of event streaming libraries' implementation details. There are many issues with streaming libraries that make them hard to use in practice, for example:

  • FRP glitches require parts of your code to be pure of side effects, and you can't easily tell which parts if your application is large enough
  • You need to remember to kill the subscriptions you create to avoid memory leaks
  • Error handling methods are ridiculously unusable
  • Event Streams are great for representing events (duh), but are not a good abstraction for representing state changing over time, and merely adding "current value" to streams does not fix that

After a lot of research, I built Airstream to solve these problems. I could write a blog post about each of them, but all my writing budget went into documentation instead because I want to live in a world where libraries are well documented.

Switching Laminar from Xstream.js to Airstream was a massive improvement to the development experience. Thanks to Airstream's Ownership feature, it was now completely impossible to forget to kill subscriptions, even ones you created manually without library helpers (a weakness of all other FRP UI libraries), and the extra boilerplate was more than worth it.

The Current Laminar

Over time that boilerplate got old, and other hard to fix design flaws surfaced. For example, you couldn't re-mount Laminar elements after unmounting them as their subscriptions were one-time-use, and wouldn't start up again after being killed.

I could have fixed all these issues independently, but for once in Laminar's history I managed to restrain my rush for immediate perfectionism, and let a more natural solution come to me. Laminar wasn't bad anymore, it was already quite good, and for end users the problems were mostly manageable with a couple rules of thumb.

Still, those design flaws never sat well with me, and were never intended to outlive me, so I started biting the bullet last fall and finally chewed through it last weekend, addressing many more problems than originally intended as the proper design crystallized in my mind.

The latest version of Laminar:

  • Features a more advanced Airstream Ownership system that fixes the memory management gotchas I mentioned
  • At the same time, makes ownership related boilerplate almost non-existent
  • While simplifying the API with fewer types and fewer but more consistent patterns

If you haven't used Laminar before, now's a great time to give it a shot. If you have, the changelog should speak for itself.


At this point I am so far away from the problems I had in 2016 that I have almost forgotten about all this. And for once in Laminar's history I don't have new massive problems ahead. I'm sure this respite is only temporary, but I would like to savor this moment.

Laminar exists, and is nice to the touch. Scala.js UI development is Laminar-smooth now, as far as I'm concerned. This is all I wanted when I started.


Cover photo is a figure from this Dragonfly flight research paper.

Discussion (9)

pic
Editor guide
Collapse
theodesp profile image
Theofanis Despoudis

Nice, but let's be honest: Scala.js UI development is something that would not work in the long run. There are numerous stronger players in the market such as ReasonML, Elm and Typescript that have a proven user base and much better adoption. Javascript programmers are very hard to please.

I'm not even sure if even Scala is heading towards a good future TBH.

Collapse
raquo profile image
Nikita Gazarov Author

It's a tough market, but I don't know why Scala.js wouldn't work in the long run.

Rust + WASM is quickly becoming a good option as well, and kotlin.js is gradually maturing, yes. All of these languages have their tradeoffs that are differently suitable to a variety of use cases and developer preferences. It's ok to have options, we don't need a monoculture. I'm not looking for the most popular way to do things – that will always be whatever Facebook / Google is pushing for their purposes. I just want what best fits my own task at hand.

Scala.js doesn't just stand on its own, Scala has a mature ecosystem on the JVM as well, and the JS-JVM interop in full stack Scala apps is great. Both Scala and Scala.js are very competitive today, and it will only get better with the imminent Scala 3 release.

Collapse
yurique profile image
Iurii Malchenko

We'd heard that Scala was going to die for many years now, but it's still here and not going anywhere. I believe it's as good as it ever had been (as a language, community and ecosystem), and it will get even better with Scala 3 (well, we're yet to see if it actually does, but I'm betting on it :) ).

Javascript programmers are very hard to please.

In this post Nikita described why all the attempts to please JavaScript programmers were not good enough for him. And I can put my signature under each word in this post.

So instead of accepting the state of things, Nikita has built something great, with a language he liked, using the techniques that actually make sense. And I personally can't stress enough how much I'm grateful for having this tool now.

And if Scala.js or Laminar don't become the "next big thing" or the next "go-to framework of the week" - I couldn't care less. I'm doing quite a bit of React/TS at work, and every time I do - I'm reminded how much I dislike doing it. Whenever I get to use Laminar - I'm a happy developer, using a reliable tool (scalac) and a well-designed API/DSL (Laminar). ReasonML, Elm are probably good, too, definitely better that JavaScript (and I guess TypeScript), but if they use virtual DOM - I pass.

Collapse
theodesp profile image
Theofanis Despoudis

I agree, TS looks good from the outside but when you start working with it, you can taste the sourness.

I've tried Elm but It felt really weird.

Now days I'm testing ReasonML and it looks like it positions itself in the middle ground. React Developers will find it very familiar and it has good support for common libraries. Javascript interop is good and the type system is better than Typescript. It doesn't go in the extremes like Haskell and it offers a good compromise.

I've had a bad experience with Scala.js and Scala in general just because of the documentation (or the lack of it). Mainly because of the symbolic soup and the unnecessary DSLs. Cats, Shapeless, ZIO are all unnecessary abstractions in the frontend. You need something simple and effective but the language oversubscribes. I remember trying out Udash framework and was chocked by it's complexity.

Don't expect the average Javascript dev to spend 3 months trying to understand how to work with SBT or anything more complicated.

I've spend more than a month on each of the underscore.io/training/ books and after that I had constant headaches.

At the end of the day IMHP it's not worth it. There is no point abstracting the hell out of it. Scala is more suitable for backend development or someone that have invested lots of time with the language and doesn't want to part with it.

Let's not talk about convincing your manager to switch to Scala.js unless he thinks you are telling jokes.

However I could try out Laminar if it had good documentation. The key thing here to sell smart and make it easy to work with. More examples here would help.

For instance a ToDo list app, more 2 minute tutorials like this one. There are tons of people writing about React. This needs to be done with Scala.js if people want to take this seriously.

Thread Thread
yurique profile image
Iurii Malchenko

I've had a bad experience with Scala.js and Scala in general just because of the documentation (or the lack of it). Mainly because of the symbolic soup and the unnecessary DSLs. Cats, Shapeless, ZIO are all unnecessary abstractions in the frontend. You need something simple and effective but the language oversubscribes. I remember trying out Udash framework and was chocked by it's complexity.

Laminar not having these problems is the reason why I initially committed to learning it.

I did try Udash, React scala.js wrapper, and other things - and it is true that they are overly complicated.

Having been using Scala as my main language for years, I had really tough times trying to figure out those libs and understand how to use them. And I eventually give up trying, with each of them.

Don't expect the average Javascript dev to spend 3 months trying to understand how to work with SBT or anything more complicated.

sbt doesn't have to be complicated.

Though build definitions for large projects indeed tend to become complicated eventually (to the extent when one really has to understand sbt to navigate there). On the other hand, the things have improved a lot in recent years, with sbt itself getting better, as well as people getting better at understanding and not abusing it.

But for a scala.js frontend project, the build would be like a dozen lines, which one would take from a project template (one is coming soon) and likely not ever need to update or modify (except bumping up the dependency versions).

And it will just work - no need to install/manage node, npm, npx, and all the other WTFs. Even on a clean system two years later (unless maven central burns down). However, I do use webpack together with sbt (sbt to produce the resulting js, and webpack for the rest of it, like css and bundling). And honestly, I personally find webpack to be more complicated than sbt.

Also, there's Mill, which is simpler than sbt and supports scala.js out of the box. Though I went back to sbt, after all.

I've spend more than a month on each of the underscore.io/training/ books and after that I had constant headaches.

Well, Scala is a language of it's own kind, in a way. But it's actually a simple one (with a grammar definition smaller than Java's).

What gets complicated is libraries, that introduce different concepts and abstractions (like cats for FP and CT; or cats-effect for lawlful pure IO). But it's not the language that is complicated. The language is just expressive enough to allow for all these additional concepts and abstractions. And I believe it worth investing in learning and using those, regardless of language. Most people don't care, but I guess that is not my problem :)

I don't know how things are with resources for training/introducing newcomers to Scala nowadays, but back in the day I've been finding that it was indeed not perfect. But I know for sure that there are many more courses and books on Scala today (and oh boy I wish I had them 7 years ago). To name a few:

I did read the underscore.io books. I don't think I found them to be very helpful or insightful for me. Except the shapeless one :) But I knew Scala well already before reading it.

Let's not talk about convincing your manager to switch to Scala.js unless he thinks you are telling jokes.

Yes, that's the bitter reality, unfortunatelly. I wouldn't even try. In regular business, regular project - the "market proposition of devs" dictates and limits the options. But whenever I'm doing something on my own - I pick laminar.

However I could try out Laminar if it had good documentation.

I find Laminar's documentation to be pretty good (as well as Airstream's).

More examples here would help.

For instance a ToDo list app,

github.com/raquo/laminar-examples ;)

more 2 minute tutorials like this one. There are tons of people writing about React.

It's hard to compete (not to say that anyone is trying to compete here) with React (with FaceBook pouring money into it) or Angular (with Google).

But we might get more Laminar content like that eventually, who knows? ;)

Collapse
raquo profile image
Nikita Gazarov Author

Hey not sure how to reply to your other comment (omg am I getting old), I just wanted to say I'm not a fan of all the things that bother you in Scala either! Scala has several subcultures going on, including the "hascalator" FP crowd (cats / scalaz / etc.) – these projects are very prominent but there are many of us who only use these tools very lightly or not at all. My own Laminar apps don't use any of those hardcode FP libs for example.

Laminar & Airstream were designed for average but motivated developers, not academic geniuses who eat category theory for breakfast. It's extensively documented too, with examples in docs, a TodoMVC example that you can run, and just today Iurii made a full stack giter8 template that uses a plain webpack config instead of scalajs-bundler sbt plugin.

The complexity of sbt is Scala's weakness, I concur. I haven't actually used Mill yet, but its author did explain the problems with sbt very well before making his own replacement, so it's promising. I should give it a shot some time, but I haven't really suffered with sbt all that much yet.

Collapse
theodesp profile image
Theofanis Despoudis

I've used Mill and it's way better than SBT (I'm maybe biased because of this article)

Collapse
eljayadobe profile image
Eljay-Adobe

Do you like the FP parts of Scala, or the OO parts of Scala, or both?

If you just like the FP parts of Scala, you may like Elm.

(I've not used ReasonML. I've heard it described as a marriage of JS and OCaml.)

Collapse
raquo profile image
Nikita Gazarov Author

I like Scala's combination of those approaches very much. It's a sharp sword but if you know to avoid pitfalls like ten layer class hierarchies, the FP + OOP integration works very well. It's also why it's so controversial: Scala is very non-prescriptive, it's on you to make good decisions.

I find pure Haskell-style FP too constraining, and the overhead of typed effects very unnecessary on the frontend – UI applications are essentially nothing but bunches of DOM manipulation effects, and the parts that don't need to touch the DOM have very little risk of doing so even without types to enforce it, so wrapping everything in effect types is just pointless IMO.

I haven't coded in ReasonML but yes, it's just a more approachable syntax for OCaml. Should be a good FP focused alternative.