Over the past couple of days, I built my first static frontend
application using reflex
. It’s called CASAAAAA
(try
it out), and it’s an interactive fuzzy searcher for aviation terms.
The fuzzy search and the terms were already defined in casa-abbreviations-and-acronyms
,
so most of the work centred around the reflex-side of things. Because I
like a challenge, I used Nix
Flakes to pin reflex-platform
.
Overall, I’m satisfied with how it all worked out — I built CASAAAAA for
a friend and he’s very happy with it. After the jump, I’ll detail some
thoughts about the experience developing even a small program in this
stack.
Nix Flakes have become quite solid:
The documentation around the new tooling is a lot better than it used to be. Not perfect, but a huge improvement.
Compared to the old way of pinning dependencies (manually
fiddling ref
and sha256
fields in a
fetchFromGitHub
call), it’s now much easier to pull in
dependencies by declaring a non-flake input in flake.nix
.
(You could previously do this with niv
, which is a
great tool, but flakes are built into Nix.)
The flake-compat
library made it easy to provide support for new nix develop
and nix build
commands, as well as legacy
nix-shell
and nix-build
.
Nix’s builds work as advertised — a friend was able to run the
one-command nix build
and get the same static
webpage.
The FRP (Functional Reactive Programming) abstraction is really cool:
FRP’s precise control over event flows allows for behaviour that
respects the user’s intent. It’s quite easy to create an
Event t Text
of terms to search for, by unioning the
debounced stream of change events on the input box with another stream
that samples the input box on form submission. This gives nice
search-as-you-type behaviour as well as responding quickly if the user
hits [Enter]
out of habit.
It also gives unshakeable UI consistency - if there’s a new value to search for or a difference in search configuration, it will trigger a new search. I didn’t have to think about ensuring this; it just happened.
The development experience with jsaddle-warp
is really fun and fast. Start a reloading session with
ghcid -c 'cabal repl' -T :main
and hack on your code.
Every time it typechecks, ghcid
will reboot a server that
you can point your browser at.
Performance for computationally-intensive libraries is not great.
I had to debounce the “input text changed” event specifically because a
search for the first typed character would return many search results,
cause a lot of DOM updates, and make the browser hitch. I’m often
tempted to use GHCjs to shove some random Haskell library into the
browser (here: casa-abbreviations-and-acronyms
). It would
mean only provisioning static hosting, or allowing people to download
the files and run them locally. Unfortunately, the performance penalty
of such libraries makes the experience rather poor.
jsaddle-warp
, you’re running
native code and won’t notice this until you actually build and test the
compiled JS. Beware.I ran into two GHCjs problems with random Hackage libraries -
casa-abbreviations-and-acronyms
was trying to build
binaries that depended (indirectly) on network
(which uses
the C FFI), and monoid-subclasses
was depending on
implementation details of Text
to make some of its
instances go fast. Each needed drive-by PRs to add GHCjs conditionals,
and while the maintainers of both packages have been responsive to PRs,
I can also sympathise with maintainers who might not want
if impl(ghcjs)
or #if ghcjs_HOST_OS
sprinkled throughout their codebase.
The types used by reflex
and reflex-dom
are pretty gnarly. Partial type signatures and typed holes are an
indispensable navigation aid, but there’s an art to giving GHC enough
information that it can get started. Otherwise, it can’t infer anything
of value and the error messages are useless.
Nix makes it easy to pull random branches into a Haskell package
set, provided that you understand how the Nix Haskell infrastructure
works. I wasted a lot of time trying to figure out why Nix was
bombing out with a type error,
expected a set but got a function
. Turns out I’d
forgotten that callCabal2nix
needs the package name as an
argument. I don’t know how you’d give better diagnostics for missed
arguments, but it’s frustrating.
Even for a simple program, you can’t avoid large JS files. Even after running Closure Compiler over the output, the JS file was ~2MB in size, and took a disappointingly long time to load on an older Android handset.
It was way too hard to figure out how to
preventDefault
a <form>
’s
submit
event. While it’s in the reflex-dom
FAQ, there ought to be a better helper for this.
Linking the final output took minutes on my laptop, and
ate all available memory. I also had to add
ghcjs-options: +RTS -K2G -RTS
to prevent a stack overflow
at link time. (I suspect that both were at least partially due to the
large table of String
s in
casa-abbreviations-and-acronyms
.)
I want to say “yes”, but probably not. GHCjs is an amazing achievement, and the effort put into its surrounding tooling is genuinely impressive, but knowing how to drive it involves so much folklore. So to use this sort of stack, it seems that I’d either have to resign myself to solo projects, or be in a position to bet the house on it. Anything in-between feels like too much bus factor risk.
I expect this to improve with time. Nix has a lot of momentum behind it — even from non-FP companies — and that will make it use less of a project’s “weirdness budget”.
The other thing I find hard to accept: it’s really hard to be kind to the user’s device when using GHCjs. The JS file it generates basically contains a full Haskell runtime, so there’s a large minimum bundle size that’s slow for less-powerful devices to process. Maybe I’m an old fart and 2MB of JS is nothing by modern standards, but BACK IN MY DAY the entire shareware version of Doom was about that size.
So it seems to me that the sweet spot for GHCjs/Reflex is when most (if not all) of the following things are true:
The entire team (even if N = 1) is across the Haskell/FRP/Nix folklore, or is committed to learning it.
The frontend involves enough complex state management to justify FRP, but not so much complex computation that the browser bogs down. (I was hopeful that Purescript had a good FRP library that I could point to. Unfortunately, a quick search didn’t turn up any that looked actively developed.)
There’s enough complexity that sharing types across the frontend and backend is going to pay off.