Skip to content

Instantly share code, notes, and snippets.

@chexxor
Last active March 21, 2024 19:55
Show Gist options
  • Save chexxor/23ccf35add7dbdd33ecdd26888663140 to your computer and use it in GitHub Desktop.
Save chexxor/23ccf35add7dbdd33ecdd26888663140 to your computer and use it in GitHub Desktop.
The Elm Architecture is the wrong abstraction for the web

The Elm Architecture is the wrong abstraction for the web

In this article, I'd like to explain why I think The Elm Architecture is fine for small components, but quite harmful for websites based on pages.

Definition and Pros/Cons

First, let's clarify what I mean by "The Elm Architecture".

The Elm Architecture's webpage describes it pretty well.

The logic of every Elm program will break up into three cleanly separated parts: Model — the state of your application Update — a way to update your state View — a way to view your state as HTML

There are lots of reasons to love The Elm Architecture:

  • It is easy to understand.
  • Easy time-travel debugging, due to highly restricted state. (not a feature worth designing an architecture around)
  • Designed around update commands and pure functions, which is great for testing.
  • Seriously, it's ridiculously easy for even non-programmers to adopt.

And there are reasons to dislike The Elm Architecture:

  • Can't easily create components which encapsulate their own state.
  • Semi-encapulated components require "wiring" through every semi-encapsulated component up to the app root.
  • Leads you to think it scales to big websites, but its docs don't even mention how to handle links and URLs.

Incorrect abstractions for the web

Duplicated DOM state

A web page element is not a pure function -- it is state. A pure function can return a description of the desired state of an element, but it requires an interpreter to realize the desired state, a thing currently referred to as the "virtual DOM". This may seem like a fine abstraction, but it effectively completely duplicates the real state of the DOM, which can lead to problems. It also has an impact on run-time memory useage.

If you describe the desired state of a text box, how do you realize it? You read the current state of that element in the DOM, diff the current state with the desired state, and apply the diff. In practice, naively applying this diff will destroy some essential information about that element, such as the cursor placement inside it. Visit facebook/react Issue #955 to see all the people who have had troubles with this. I've experienced it when using React, and it's an awful feeling to know you can't easily do what you want to do while still following the architectural pattern. It's worth noting that many people have not experienced this particular issue with inputs, and I can't explain this difference in experience.

People who enjoy using virtual DOM say that the virtual DOM simplifies most all of a front-end dev's job, but there is a small percentage of things that you should bypass the virtual DOM to do. This is a relatively fair argument, but to me it indicates a poor abstraction. Virtual DOM isn't a terrible abstraction, but based on my experience, there is room for improvement.

To be fair, using virtual DOM isn't exactly described as an intentional part of The Elm Architecture, but the architecture relies on it! The "View" should be a pure function of the "Model", and the interpreter should flawlessly realize the desired "View" into the DOM. There are situations in which the desired can't be flawlessly realized, such as text field inputs with cursor position and selected text, as I experienced.

The purescript-purview and purescript-panda libraries offers an alternative to the virtual-dom while staying close to the "desired state of the DOM" idea. Also, Phil's purescript-sdom is a slightly different option, one which sees what a virtual-dom-like library would look like if the state management and DOM tree recreation & diffing was removed. As it appears, the most obvious thing you lose is the ability to have a single template create variations on a DOM tree, but this can be worked around using CSS rules to control node visibility.

Unique "Update" for every state update

How does an element make an HTTP request? In its "Update", it has a branch which creates a value representing an HTTP request along with a callback function (which must be an "Update" value in your app). The Elm runtime will call your update function with the value, and your app carries on using this value.

-- From http://elm-lang.org/examples/http
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    MorePlease ->
      (model, getRandomGif model.topic)

    NewGif (Ok newUrl) ->
      (Model model.topic newUrl, Cmd.none)

    NewGif (Err _) ->
      (model, Cmd.none)

These update commands are a combination of user-triggered actions and system-plumbing actions. The user of the app will trigger the MorePlease action, and the HTTP response arrives on the NewGif action. Why do system-plumbing actions have to be there?

I can imagine a better way of doing this would be to move the "View" down from its own function and move it into the same level as the other effects, like HTTP. This would require changing The Elm Architecture, of course, but look at how much more simple it would be:

-- Pseudocode, but should work when executed with an extensible effects system.
app :: Effects Unit
app = do
  userAction <- (homePage Nothing :: HTML Action) -- render home page, no article text
  case userAction of
    ClickLoadArticle articleId -> do
      article1 <- (getArticle :: HTTP String) -- HTTP request to get article text
      nextAction <- homePage (Just article) -- render home page, with article
      ... -- call `app` again? idk.
    ClickClose -> do
      emptyPage -- remove all content
      return unit -- end the program. Might want to avoid this. :)

No need to define a new "Update" action to receive the HTTP response. This means we don't pollute a single "Update" action type with actions of unrelated origin. The system I pseudocoded above needs a bit of organization, of course; perhaps some coroutines could clean up that pseudocode by transferring control to dramatically different views. The purescript-concur library shows that this is possible, and gives several nice examples in the "examples" directory.

The pseudocode above would work better with an extensible effects system. While the Haskell and PureScript have libraries for expressing extensible effects, the Koka language has it built-in to the language, as demonstrated in Daan Leijen's, member of the Microsoft Research group, talk titled "Daan Leijen - Asynchrony with Algebraic Effects".

I feel like The Elm Architecture's choice of making view-rendering a non-effect, on a different level than other aspects of an app, can be called a mistake. It complicates doing common web app activities, like HTTP requests, which is more support towards my claim that The Elm Architecture is the wrong abstraction for a web app.

URLs and support for different paradigms

I think the Elm Architecture only supports a single view library and renderer. This is expected, actually, as I think it considers itself a framework, as a framework is a prescribed set of tools. It's not really a framework, though, because I consider a framework to be a prescribed set of tools, abstractions, or default implementations for a wide variety of real-world application concerns. Hopefully the framework's author would be a highly experienced software developer, as that fact would provide the trust that the specific set of tools are smart choices.

Anyways, in a Big Website, there will be multiple teams -- one team owns the home page, one team owns the searching and search results pages, one team owns the checkout & payment pages, one team owns the help center pages, and one team owns the public API documentation pages. It's an awful idea to expect all of these teams to use the same framework -- it's much more effective to allow different teams to try different designs and architectures.

What I mean here is that "/" and "/checkout" need to support completely different app architectures. Maybe the "/" page team wants to use a simple HTML string and host a little JavaScript file which embellishes just the search box on the home page with event listeners. We should allow the "/checkout" team wants to use React and the "/dashboard" team to use Elm or similar.

How does this relate to The Elm Architecture? The URL is a special thing in a web app -- it enables linking to a view. Most commonly, the content associated with a URL should be nearly identical to all who view it. The Elm Architecture says that the "View" is a function of the "Model", but web users expect the "View" to be a function of the URL. This is quite the contradiction. If we follow The Elm Architecture, we need to put the URL into the "Model", and indeed that's what the elm-lang/url package does, as can be seen in its examples/Example.elm file

This is another case of duplication of state! Just like in the virtual-dom idea! It seems fine at first, but it's very possible that the browser's URL and the JS app's URL will diverge. The only fix here is for the JS app to find the times in which that occurs and reconcile the system causing it.

If in-browser rendering is the desired thing, then the in-browser app needs an abstraction closer to HTTP handler. Consider the first time an HTTP page is requested -- it is handled by a server-side HTTP handler, right? Why can't we put an HTTP request handler in the browser? Yes, it could respond with an HTML string, but it could also respond with instructions to load a JavaScript file which patches the head and body of the existing document. The in-browser HTTP handler's main responsibility would be finding, loading, and activating the requested URL, in addition to intercepting HTTP requests to the server and fulfilling them itself, of course. I don't have a specific implementation of this to point to, but I'd like to try writing one. Let me know if you know some good ones.

With this point, my criticism is less about The Elm Architecture and more about its communication. It says The Elm Architecture is a simple pattern for architecting webapps", but it doesn't mention the URL anywhere! The "URL" library exists as a core library, seems to have the blessing of The Elm Architecture author, and seems to have no documentation about how it should fit into The Elm Architecture or the types of apps which it should be applied to. This leads newcomers to Elm to believe that it can be used to build any URL driven website they want, which, while possible, shouldn't be recommended.

Code-splitting and lazy-loading routes

TEA has no comments on code-splitting and lazy-loading modules/routes. My TEA-following app has grown to a size that takes nearly 20 seconds to load on first page request. It's so regrettable! TEA-supporters could argue that "but routes and URLs aren't part of TEA!", to which I couldn't disagree, but where is the warning signs in the routing-related core libs that TEA doesn't scale to large websites!

You won't discover this fact until you've gone too far down TEA road that you can't easily rebuild it in a more appropriate architecture for such an app. To support code-splitting & lazy-loading parts of your app, those parts need support of a dynamic module import mechanism, like ES6+'s import("Abc").then(function(m) { m.abcExports; }) or the non-standard webpack-originating require.ensure([], function() { var m = require("Abc"); }). Lazy-loading is an asynchronous module loading operation, a thing which TEA doesn't describe at all. I've spent a lot of time thinking about it, and I've decided that there are at least 3 ways of architecting an app to have lazy-loading routes.

TEA currently expects every route to be synchronously rendered, and each route supported by the app is bundled with the main app. I suspect asynchronous route-loading could be done at the top-level of the app, but I haven't seen a working example of it, and I haven't tried it yet. To illustrate, I imagine it would look something like this:

module App.Main
...
update (PageView route) state =
  { state: state
  , effects: [ do
      let filePath = filePathOfRoute route
      -- Example of `filePath` is "./pages/VerifyEmail.js".
      -- Dynamically import the file.
      loadedRoute :: _ -- What type would this be?
                       -- `{ main :: Eff _ Unit }`?
                       -- `{ view :: _, update :: _ }`?
          <- dynImport filePath
      pure $ LoadedView loadedRoute
    ]
  }
update (LoadedView loadedRoute) state =
  -- How to store child view on parent's?
  -- How to direct child view's events from root to it?
  mapEffects ViewAction
    $ mapState ( state { viewState = _ } )
    $ loadedRoute.update state.viewState

It's complicated! I think these complications can't just be hidden by the compiler, as the error state needs to be considered. Also, it seems like each page would need to be a separate TEA app, because the requirements of the parent-to-child communication doesn't fit so simply.

There are some companies which have Very Large (200,000+ LOC) Elm apps which use TEA. Lucky for me I was able to get an answer to this problem from Richard Feldman, who works with one of those apps. He said that yes, code-splitting is a problem, but they've mitigated the problem by splitting their web app into several smaller Elm apps. This was very relieving to know! I was under the impression that if you're properly applying TEA, you can just keep growing your app without bound! So it seems the answer to code-splitting is to do it outside your TEA app. Actually, this is an technique I can agree with -- I only wish caveat and its solution was advertised on TEA docs.

Components & re-using 3rd-party components

As I can see, there is no way to implement "stateful components" in TEA, which is really quite strange! The native "input" element is stateful, so it's easy to see that stateful elements are a native concept to the web.

!!! To do, add more info on the necessity of components.

!!! Add experience report from Elm folks who say that components can be had relatively simply by writing them outside of Elm, perhaps as a web-standard custom element, and importing them into your Elm app through its FFI, using them as if they were native HTML elements.

@Erudition
Copy link

It seems fine at first, but it's very possible that the browser's URL and the JS app's URL will diverge. The only fix here is for the JS app to find the times in which that occurs and reconcile the system causing it.

Guess you've never met Browser.application. Maybe this was written before that was a thing? Not sure how you can miss all the URL handling stuff in the docs. It all works quite well, actually.

Anyway, personally I'm glad that Elm tries to do things differently (like pure function components rather than tons of state containers) rather that bowing down to the abstractions that happen to currently exist natively on the messy web platform we have.

@zlumer
Copy link

zlumer commented Mar 21, 2024

Hey,
When I used TEA for the first time, I was so surprised by it's expressiveness and robustness that I hoped to use the TEA approach more. To use it in React I've developed a small library a few years ago called TESM https://github.com/zlumer/tesm
The docs are a little bit outdated and it might require some time to set up, but in the 4 years since it's first release I've used multiple other state libraries and haven't encountered anything even remotely as usable.
Maybe you can have a look at TESM and offer me some critique on what you don't like? I will provide you with the test code and fix the docs in the process.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment