Alternative View Layers for an Elm App

Pablo Brasero

In the current, fast-changing JavaScript scene, there are two projects I am following more closely than the rest: Elm and Ember.js. Recently I had the opportunity to experiment with both at once, as I worked out how to use Glimmer (Ember.js’s rendering engine) as a view layer for Elm code.

This is a write up of my solution. It is actually divided in two parts:

  1. Using an external view layer with Elm.
  2. Messaging to/from a Glimmer application.

This will not be a comprehensive walkthrough, but rather will cover the most important details of how to build the “glue” between these two pieces. You can check out the complete example on Github if you want to know more or simply play with the result.

Using an external view layer with Elm

Elm’s own view layer

Something that can be surprising to Elm newcomers is how to work with its view layer. Instead of writing the HTML templates that are commonplace in other environments, developers and designers are expected to use the Elm language itself to describe the output. For example, where we normally would have something like the following:

<tr>
  <td class="item-id">#{{item.id}}</td>
  <td class="item-name">{{item.name}}</td>
</tr>

With Elm we would define the following function instead:

itemView : Item -> Html Msg
itemView item =
  tr []
     [ td [ class "item-id" ] [ text ("#" ++ item.id) ]
     , td [ class "item-name" ] [ text item.name ]
     ]

This gives our templates the benefits of Elm’s compile-time type checking, helping us to identify many errors in our code immediately. On the other hand, it is a bit more verbose, arguably inconvenient, and it can be difficult to handle when converting existing HTML, or generally liaising with members of the team implementing the views.

Whether the tradeoff is acceptable or not is for your team to decide. It is worth pointing out though that Elm’s type checking is a major boon of the language, and you should not disregard it lightly.

Having said that, are there alternatives to this? Is it possible to work with Elm while keeping our “classic” HTML-based templates? The answer is yes. At least two options, actually.

Alternative view layers

The first option would be to write an Elm library to handle this. For example, the core library elm-lang/html implements Html.program. This expects (among other things) a view argument that is the basis of Elm-based templates like the one in the example above. A custom library would handle things differently, capturing DOM events, notifying the update function, and refreshing our view layer when the model changes. We could call it foobar/template and provide Template.program.

The problem with this approach is that it requires certain knowledge of Elm’s internal APIs, or at least enough to write a library of this type. I do not possess this knowledge, so I had to look for another, simpler option.

This second option is: instead of using Html.program, completely eschew the HTML library and create a “headless” program using Platform.program, which takes the usual arguments except for view. You can then communicate with your view layer using Elm ports.

Example headless Elm program

Let’s try put something together. This would be an example Elm program without a view layer. It expects messages to come in through a port. When the model changes, the program will send it out via another port:

port module Main exposing (main)

import Json.Decode -- A bug in Elm forces us to explicitly require this

type alias Model =
    Int

port increment : ( Int -> msg ) -> Sub msg
port reset : ( () -> msg ) -> Sub msg
port render : Model -> Cmd msg

type Msg
    = Increment Int
    | Reset

subscriptions : Model -> Sub Msg
subscriptions model =
  Sub.batch
    [ increment (\amount -> Increment amount)
    , reset (\_ -> Reset)
    ]

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  let
    newModel =
      case msg of
        Increment amount ->
          model + amount
        Reset ->
          0
  in
    (newModel, render newModel)

main : Program Never Model Msg
main =
  Platform.program
    { init = (0, Cmd.none)
    , subscriptions = subscriptions
    , update = update
    }

This program exposes these ports:

  • render (outbound): will be passed the model when this changes.
  • reset (inbound): will set the model to 0.
  • increment Int (inbound): will increment the model by the given amount.

We can now write some HTML and JS that load the above Elm program and interacts with it. For a simple start, let’s just not render anything. We can use the browser console for now. If the Elm code is compiled into a file called elm.js, we can load it as follows:

<html>
  <head>
    <title>Elm experiment</title>
  </head>
  <body>
    <script type="text/javascript" src="elm.js"></script>
    <script>
      var elmApp = Elm.Main.worker();
      window.elmApp = elmApp;

      elmApp.ports.render.subscribe(function(model) {
        console.log("RENDER", model);
      });

      function actionIncrement(value) {
        elmApp.ports.increment.send(value);
      };

      function actionReset() {
        elmApp.ports.reset.send(null);
      };
      actionReset();
    </script>
  </body>
</html>

We open this on a browser (as a file:// is ok) and get a beautiful, exciting… blank page. But remember we were not rendering anything yet. We will work with the console. Opening the inspector will reveal some output present already: RENDER 0. We can interact with the Elm program using the interface we created. This is an example session:

RENDER 0
> actionIncrement(1)
RENDER 1
> actionIncrement(2)
RENDER 3
> actionIncrement(-7)
RENDER -4
> actionReset()
RENDER 0

We now have a clear interface to send messages to Elm, as well as for Elm to tell us about changes in the model. We can integrate this with any JavaScript code, including templating libraries. Time to wire one up.

Communication to/from a Glimmer application.

A basic Glimmer setup

I will not go into detail on how to build an Elm and a Glimmer applications side to side on the same project. You can check the example app to see a simple way of doing it. For now, let’s just assume we have a working project where both applications are building correctly. At a minimum, it will involve altering the HTML above with a change like the following, where app.js is the Glimmer code, which in turn expects a container element with id app where results will be rendered:

   <body>
+    <div id="app"></div>
+
+    <script type="text/javascript" src="app.js"></script>
     <script type="text/javascript" src="elm.js"></script>

Messaging

At the time of writing, standalone Glimmer (as opposed to integrated into Ember) is beta software and there are some outstanding issues. The one that is important to us now is that Glimmer does not provide a way for external code to communicate with its components: we cannot pass data or notify of changes that occurred in the outside.

This should be addressed at some point this year, and in fact there’s a pull request in the works as I write these lines. But until such time that this makes it into mainline Glimmer, there is a web API that we can use for this purpose.

To work around Glimmer’s limitation, I have been using window.postMessage successfully. If you are familiar with this Web API, you will know that it is commonly used to send messages to other frames or windows in the app, but it can also be used to send messages within the current window, to any code that may be listening to them.

From Elm to Glimmer

Let’s see how it would work for us. First, from our HTML glue code we can send the model to the template when Elm notifies us of a change:

       elmApp.ports.render.subscribe(function(model) {
-        console.log("RENDER", model);
+        var msg = {
+          origin: 'core',
+          target: 'view',
+          action: 'update',
+          payload: { model },
+        };
+        window.postMessage(msg, window.location.origin);
       });

Then, in the top-most component of our Glimmer app, we can set a listener for postMessage notifications:

import Component, { tracked } from '@glimmer/component';

export default class ModelDisplay extends Component {
  @tracked model = null;

  // ...other component code here...

  didInsertElement() {
    window.addEventListener('message', (evt) => {
      if (evt.origin !== window.location.origin) {
        return false;
      }

      let data = evt.data;
      if (data.origin !== 'core' && data.target !== 'view') {
        return false;
      }

      let { action, payload } = data;
      if (action === 'update') {
        this.model = payload.model;
      }
    });
  }
}

This is enough to get a first render. If your Glimmer component has a representation for the initial model of the Elm app, it should appear on screen now.

From Glimmer to Elm

Next, we enable communications in the opposite direction: from the Glimmer component to the Elm app. To do this, we use very similar code, only reversed. For example, if our Glimmer component has two actions increment and decrement, these will look like follows:

  // ...

  increment() {
    this.postIncrement(1);
  }

  decrement() {
    this.postIncrement(-1);
  }

  postIncrement(amount) {
    let msg = {
      origin: 'view',
      target: 'core',
      action: 'increment',
      payload: { amount },
    }
    window.postMessage(msg, window.location.origin);
  }

  // ...

As you trigger the actions (pressing buttons or whatever is appropriate), the “glue” code will receive these messages. From there we will forward them to the Elm app through its inbound ports. The code will mirror the one receiving messages in the component:

window.addEventListener('message', function(evt) {
  if (evt.origin !== window.location.origin) {
    return false;
  }

  var data = evt.data;
  if (data.origin !== 'view' && data.target !== 'core') {
    return false;
  }

  var action = data.action;
  if (action === 'increment') {
    actionIncrement(data.payload.amount);
  }
});

That is enough to get the increment/decrement actions going. Implementing the reset is very similar and left as an exercise to the reader ;-)

Performance

I wondered about how efficient this would be, sending these messages across using an asynchronous API and all. For a simple benchmark, I built a simple app that shows a large table and updates random rows at the push of a button. I actually wrote three different simplementations: pure Elm, pure Glimmer and hybrid. Check it out and compare yourself.

From what I can see, there is no significant difference between the Elm and hybrid versions, but the Glimmer-only version performs way better. But take all this with a pinch of salt anyway.