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).
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 receive
s 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 <[email protected]>
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".