Permalink
Find file
Fetching contributors…
Cannot retrieve contributors at this time
338 lines (250 sloc) 14.6 KB

Elm: Commands and Subscriptions and Ports. Oh My!

Two weeks ago, I waxed damn near poetic about the Elm programming language. Since then, I've made fully functional my first Elm webapp, Kakuro-Dojo. I still have plenty more features to add, but it's playable, and lays out nicely on mobile (well, at least on my iDevices).

Elm Logo


Logo Credit: @elmlang

In this article, I'm going to attempt to explain three things that I found difficult to understand: commands, subscriptions, and ports.

Commands and Subscriptions

We'll start with commands and subscriptions, and then add ports.

First, download some code from GitHub, and aim elm-reactor at it:

git clone https://github.com/billstclair/elm-csp.git
cd elm-csp
elm reactor

Aim your web browser at http://localhost:8000, and click on "csp.elm" (NOT "csp2.elm"). You should see a big random number between 1 and 100 that changes every second and a "Time" counter that increments every second. If you refresh the browser, the time will return to 0.

This code basically puts together the Random and Time examples on the Elm Learn by Example page. They're explained in some detail in the Get Started book. But, though those examples work, for some reason I didn't grok commands and subscriptions right away.

The important parts of this example are the Msg type and the update and subscriptions functions:

-- UPDATE

type Msg
  = Tick Time
  | NewRandom Int

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    Tick newTime ->
      ({ model | time = model.time + 1 }
       , Random.generate NewRandom <| Random.int 1 100
       )

    NewRandom r ->
      ( { model | random = r }, Cmd.none)

-- SUBSCRIPTIONS

subscriptions : Model -> Sub Msg
subscriptions model =
  Time.every second Tick

Subscriptions

Time.every takes a Time (number of millisecond ticks) as first arg and a function from Time to msg, (Time -> msg), as second arg. It returns a subscription to that same msg type, Sub msg. I use Tick as the function from Time to msg, it returns not just a generic msg type, but an instance of Msg, specifically, Tick Time.

The subscriptions function gets a Model as input, but in this example, it ignores that.

Bottom line, this causes Tick to be called once a second with an argument of the current Time, in milliseconds (but use Time.milliseconds, Time.seconds, and friends to insulate your code from clock precision). Tick produces a Tick Time Msg, which is passed by the Elm runtime to to the update function.

You won't always want to use one of your Msg functions directly. For example, since our update function doesn't use the time, we could have defined Tick without an argument, and ignored the time argument when we created that message:

Type Msg
  = Tick
  | NewRandom Int

subscriptions : Model -> Sub Msg
subscriptions model =
  Time.every second (\time -> Tick)

(You may need a refresher on anonymous functions)

Commands

Now that we have the Elm runtime delivering us regular Tick messages, what do we do with them:

update msg model =
  case msg of
    Tick newTime ->
      ({ model | time = model.time + 1 }
       , Random.generate NewRandom <| Random.int 1 100
       )

    NewRandom r ->
      ( { model | random = r }, Cmd.none)

Random is an effects module. It keeps its own internal random seed. You can keep track of the seed yourself if you want, and then you wouldn't need the command to pass through the effects module and come back, but I'm documenting commands here, so that's what I did.

Random.generate takes as first arg a function that maps an Int to a msg. We're passing it the NewRandom function. Again, we're using the type function itself. But it could be any function that takes an integer as input and generates a NewRandom message. I could have coded it as:

makeNewRandom : Int -> Msg
makeNewRandom r =
  NewRandom r

update msg model =
  case msg of
    Tick newTime ->
      ({ model | time = model.time + 1 }
       , Random.generate makeNewRandom <| Random.int 1 100
       )
    ...

The second arg to Random.generate is a Random.Generator. I'm passing Random.int 1 100 for that, which generates a random integer between 1 and 100.

Random.generate returns a command. We don't have to care about which type of command, we just return it as the second element of the (Model, Cmd) tuple, and the runtime will pass it to the Random effects code, which will generate the random number and pass the resulting Msg back to our update function. When we receive it, we just stuff it into the Model, and we're done:

    NewRandom r ->
      ( { model | random = r }, Cmd.none)

Ports

csp2.elm adds ports to csp.elm. Since the code that implements a port needs to be coded in JavaScript, you need to have an HTML file to contain the JavaScript code. index.html is that HTML file. You can't run a port module in elm-reactor. In order to run it, you need to compile the elm into JavaScript, and refernce that JavaScript in the HTML file:

cd .../elm-csp
elm make csp2.elm --output csp2.js

Then you can open index.html in your browser.

The following line in the <head> section of index.html is where the Elm code gets included:

  <script type="text/javascript" src="csp2.js"></script>

I copied my port code from Evan Czaplicki's TodoMVC example. You can read more about JavaScript interop with ports here.

In order to have ports in a module, you have to declare it to be a port module:

port module Csp exposing (..)

That requires you to use App.programWithFlags, so that your init function will get an argument from the JavaScript code:

main =
  App.programWithFlags
    { init = init
    , view = view
    , update = updateWithStorage
    , subscriptions = subscriptions
    }

...

init : Maybe Model -> (Model, Cmd Msg)
init maybeModel =
  let model = case maybeModel of
                   Nothing ->
                     { version = modelVersion
                     , random = 0
                     , time = 0
                     , name = ""
                     }
                   Just m ->
                     m
  in
      (model, Cmd.none)

The first arg of your init function can be of any type you want, as long as the JavaScript code passes the JavaScript version of that type. If you put Maybe in front of the type, then the JavaScript code can pass null to denote Nothing. From index.html:

// Must match modelVersion in csp2.elm
var modelVersion = 2;

var name = '';
var storedState = localStorage.getItem(storageName(name));
var startingState = storedState ? JSON.parse(storedState) : null;
if (startingState && startingState.version != modelVersion) {
  startingState = null;
}
var csp = Elm.Csp.fullscreen(startingState);

localStorage is a simple key-value store implemented by modern browsers that enables caching data in persistent storage. It's implemented as an SQLite database file for each domain that uses it in my macOS browsers. Read about it here.

The save code in index.html simply converts what it gets to and from a JSON-encoded string, with one exception. Because an application's saved Model tends to change, it's good to have a version field in that record, so that you can recognize old saved state and either throw it out, which is what my code does above, or convert it to the new version, which is what you should do in a real, production-quality application (see the note at the end of this article).

This brings up an issue of how Elm data structures are represented in JavaScript. From that Interop page:

  • Booleans and Strings – both exist in Elm and JS!
  • Numbers – Elm ints and floats correspond to JS numbers
  • Lists – correspond to JS arrays
  • Arrays – correspond to JS arrays
  • Tuples – correspond to fixed-length, mixed-type JS arrays
  • Records – correspond to JavaScript objects
  • Maybes – Nothing and Just 42 correspond to null and 42 in JS
  • Json – Json.Encode.Value corresponds to arbitrary JSON

The only other issue is how the JavaScript starts up the Elm code:

var csp = Elm.Csp.fullscreen(startingState);

Elm is defined by the JavaScript code generated by the compiler in csp2.js. Csp is the name of the module:

port module Csp exposing (..)

fullscreen is the name of the function that starts up your Elm code with control of the whole screen and passes startingState to the init function. If you want to code some of your window's appearance in the HTML file, you can call Elm.Csp.embed. Search for ".embed" on that interop page for details. The Elm architecture ensures that data coming back into Elm from JavaScript always satisfies type constraints. If your JavaScript code is faulty, you'll get JavaScript errors, meaning you'll become friends with the "Debuger Tools" in your browser while working on that small part of your application, but once the data gets back into Elm, you're once again in error-free territory.

On to the ports themselves. An individual port can be used for output to the JavaScript or for input from the JavaScript, but not both. Sometimes they come in pairs. My application has one output port and one input/output pair. Output ports are functions that return a command. They are created in the update function. Input ports are functions that return a subscription. They are part of the application's subscribe function. Here are the three ports in my example:

-- save (name, model)
port save : (String, Model) -> Cmd msg

-- request name
port request : String -> Cmd msg
port receive : (Maybe Model -> msg) -> Sub msg

The save port is output only. It is used to save the game state. Its first argument is the name of the saved game, which is typed into the text box in the UI. Its second argument is the Model. The save is initiated by the Save message, created by the "Save" button:

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    ...
    Save ->
      ( model, save ( model.name, model ))
    ...

A save to the blank name (""), which is the one restored when the page loads, happens automatically in the updateWithStorage function, which is the actual "update" function for the application:

main =
  App.programWithFlags
    { init = init
    , view = view
    , update = updateWithStorage
    , subscriptions = subscriptions
    }

updateWithStorage : Msg -> Model -> ( Model, Cmd Msg )
updateWithStorage msg model =
  let
    ( newModel, cmds ) = update msg model
  in
      ( newModel
      , Cmd.batch [ save ("", newModel), cmds ]
      )

[Cmd.batch changes a list of commands into a single batch command.]

The save port invokes the following JavaScript in index.html. Note how it pulls name and state from the Elm (name, state) tuple:

csp.ports.save.subscribe(function(pair) {
  var name = pair[0];
  var state = pair[1];
  var json = JSON.stringify(state);
  localStorage.setItem(storageName(name), json);
});

Finally, in order to read a saved state, the "Read" button invokes the Restore message, which call the request port to create a command:

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    ...
    Restore ->
      ( model, request model.name )
    ...

That invokes the following JavaScript in index.html, which reads the JSON string from the database, parses it, ensures that its version is correct, and sends it back to the Elm code through the receive port:

csp.ports.request.subscribe(function(name) {
  var json = localStorage.getItem(storageName(name));
  var state = json ? JSON.parse(json) : null;
  if (state && state.version != modelVersion) {
    state = null;
  }
  csp.ports.receive.send(state);
});

We subscribe to those receives in the modified subscriptions function:

subscriptions : Model -> Sub Msg
subscriptions model =
  Sub.batch [ Time.every second Tick
            , receive Receive           -- ***HERE'S THE SUBSCRIPTION***
            ]

And we process the Receive message in the update function:

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    ...
    Receive maybeModel ->
      let model' = case maybeModel of
                       Nothing -> model
                       Just m -> m
      in
          ( model', Cmd.none)

Conclusion

I hope that this article makes it a little easier for you than it was for me to grok commands, subscriptions, and ports.

If you need to send some data out of your Elm code to somewhere you can't get in normal Elm code, you're probably going to use a command.

If you need to receive some data from outside, you're probably going to use a subscription.

One of the places you can send out data with a command or bring it in with a subscription is JavaScript code. You do that with a port.

Bill St. Clair <billstclair@gmail.com>
26 October 2016


Note that it's generally considered bad practice to rely on the compiler-generated conversion code for converting your port parameters to and from JavaScript. It's better to pass only strings, and to explicitly convert Elm data structures to and from JSON strings. This makes your job much easier if you have to do schema update when you change the form of the saved data. And you'll also avoid a compiler bug, possibly fixed by now, but there when I wrote this on 26 October 2016, that causes a run-time error in the auto-generated conversion code if any of your record fields is named "default".