Thoughts on Elixir, Phoenix and LiveView after 18 months of commercial use

I’ve been leading a team developing an application using Elixir, Phoenix and LiveView for the last 18 months and accumulated some thoughts on the stack. For the most part, it has been a very pleasant experience.

Compared to my initial evaluation of Elixir that I’d done prior to diving in in earnest, it exceeded my expectations in terms of availability of tools & packages and the overall quality of dev experience.

Prior to Elixir, I was working primarily with Elm which probably colours my experience, but I have used a bunch of languages and have switched from static to dynamic types and back several times.

Elixir

First of all, I find Elixir quite enjoyable:

  • The combination of FP features and BEAM features is really good.
  • The concurrency model is really intuitive and powerful.
  • There is just enough FP to satisfy everyday needs, provided in a way that makes it easy for non-FP people to get comfortable quickly.
  • There is no ceremony involved with effects, which is sometimes very convenient and sometimes very lax (much like dynamic typing).
  • The language isn’t overly bloated and is very stable at this point.
  • Pattern matching is quite a powerful feature and becomes second nature very quickly (although it can be overused for extracting nested values).
  • Pipelines are of course very handy and used extensively. Coming from Elm, I found the pipeline operator weird in that it literally shunts the first argument into the function argument list instead of being a proper operator that you get with curried functions (it confuses code completion too!), but on the plus side its limited nature prevents the excesses of point-free style of programming.
  • Surprisingly, I don’t miss currying as much as I expected. I was initially quite wary of this.
  • The deployment story is quite straightforward these days, which is particularly helpful in cloud environments.

As the pipeline example illustrates, Elixir seems to me to be one of those projects where instead of chasing the perfect design of every feature it’s OK to put in place pragmatic solutions even if they have warts. Some things are messy as a result but that’s an acceptable compromise:

  • Parts of the standard library are clunky; I’m thinking about the combination of functions for working with data structures inEnum/Map/List/Keyword and Kernel, or things like MapSet instead of Set, or charlists (needed for compatibility with Erlang), or inconsistent naming (is_map vs empty?).
  • There are things that are unnecessary, such as unless or the ability to define some custom operators, or all the different ways to access values in maps & structs that seem to be a bit confusing to newcomers.
  • Similarly, I don’t like comprehensions as everything they do can be achieved with functions, and they are not composable.
  • I can see why keyword lists exist but I don’t like that they overlap in functionality with maps. It’s another pragmatic solution that won over finding a more elegant way to support named arguments.
  • Grouped aliases/imports/requires are a mis-feature in my view. They save a few characters when typing but they complicate searching for module uses and refactoring (unless all your unqualified module names happen to be unique). Languages should really optimise for reading code! Luckily there is a Credo check that disallows grouping.
  • Having different syntax for calling anonymous functions (f.()) is weird for an FP language.
  • Outside the language itself, ExUnit is pretty good but setup for tests accessing a DB is finicky, and I wish there was a way to warn on tests without asserts (we’ve had a production bug slip through due to an accidentally removed assert).

Phoenix

My experience with Phoenix has been almost entirely with a LiveView lens, so I don’t have that much to say about Phoenix itself. It seems to be a reasonable MVC framework. The very welcome addition of compile-time checked verified routes in Phoenix 1.7 fixed one of my complaints about dealing with routes.

Another thing I don’t like is the default separation of HEEX templates from the rest of the view code. For any non-trivial views, this approach doesn’t scale and you end up with HEEX in your view code anyway, so why not keep all of the template together with the code from the beginning and benefit from improved locality?

I’m still somewhat unsure about the purpose and value of contexts. The idea is that contexts somehow provide a nice API for related functionality, sitting in front of a bunch of other modules with the actual implementation and hiding details of things like DB and external API access. There is even a longish guide about the use of contexts, but in the end it’s still unclear where and how to draw the context boundaries in practice.

For example, if I have to deal with users who have accounts which belong to companies and I want a function which gets me the stats on active accounts by company combined with each user’s last login time, should that function live in the Users, Companies or Accounts context? Should I even have these three contexts, or should I just have one Accounts context? It turns out that this isn’t easy to answer.

My approach to date has been to use contexts as the effectful infrastructure layer on top of the functional core. I also try to avoid multiplying contexts unnecessarily. The idea is that there shouldn’t be one context per model but only one for each of the “central” entities in the system, so for example in the oft-used blog implementation, I might have a Users context which deals both with users and their accounts, and an Articles context which deals both with posts and their comments (eg if we’re only interested in getting comments for posts and not for specific users).

LiveView

What’s great:

  • Having a single code base for backend and frontend is great in terms of simplifying the mental model and also allowing people to work on “vertical” slices of functionality, which I think is far more effective than trying to break work by layers such as frontend & backend.
  • Purely functional management of state in assigns (putting live components aside) is also a great simplification (and is a comfortable transition from Elm).
  • Being able to write UI interaction tests for live views directly in ExUnit, without relying on complex tools like Selenium (or whatever the latest alternative is) has been very useful. Of course, functional components being pure functions is also a boon for testing.

What’s not so great:

  • It’s unfortunate how the authentication logic (and any kind of state-based restrictions really) needs to be duplicated in an on_mount hook in addition to being defined in plug pipelines in the router. Relatedly, live_session feels like a hack that is required to enforce running through the Plug pipeline in certain situations. It feels like LiveViews actually need their own equivalent of Plug pipelines.
  • Storing client data is not particularly straightforward because websockets naturally limit the opportunities to store stuff in cookies, and using localStorage is also not quite trivial.
  • Live components are best avoided if possible, in my view. The documentation is quite bullish on them, presenting them as just another thing alongside functional components, but I think they should be more of a last resort, with functional components used whenever possible. This is because live components immediately raise questions about what is the source of truth for the state (view or component), they are harder to test given that they are stateful, and they have some warts like inconsistency in how messages have to be passed depending on whether the live component is a child of a view or another live component (although I know this is on the roadmap to be fixed).
  • We occasionally see weird bugs that look like events being routed to the wrong place or hiccups in reconnection logic. I guess we knew what we were getting into, seeing as LiveView hasn’t reached 1.0 yet.
  • I’m not a fan of the ad-hoc DSL that’s emerging within HTML attributes in HEEX templates (:if etc.). It means there are two or three different ways to express logic in templates, and it’s yet another thing to learn.

Conclusion

Both Elixir and Phoenix are very solid tools although of course they have their share of things that I don’t agree with. LiveView is an extremely impressive addition to Phoenix that melds backend and frontend, and is just on the cusp of becoming a mature option for more complex applications.

Discussion on HN (where Jose Valim agrees with my thoughts on functional components vs live components, yay!)

Discussion on Lobste.rs