How to Write Elm Ports in ReasonML
bs-elm-es6 bucklescript documentation elm reasonmlRecently I've published an npm package called bs-elm-es6 and put it into production on a couple of projects. It's documented briefly by its README, but I think it deserves a full post. This post will walk through how to set up ports both into and out of an elm 0.19 project using BuckleScript 7. (If you're curious, I'm deferring decisions about the rebrand/new syntax until we get the new npm package.)
The Goal: shared control between ReasonML and Elm through ports
The final product is intended to be minimally reproducible and easy to understand, not necessarily useful. In this case, I think the best page to show the features of this very small library is a very small web app--an app with two text boxes that show the ReasonML app and the Elm app communicating in real time. You can find such an app here.
Take a moment to play around with the two text boxes. The first one lives in Reasonland, but on its input event, Reason sends its content into the Elm app. The second lives in Elmland, but on its input event, sends its input to the Reason scripts through another port. The result are two text boxes that always match.
Ordinarily, I would never have a textbox that lives outside the elm app--I'd give control of the whole view to Elm, but it's easy to imagine that the app instead has ports to something like an IndexedDB repository, in the case of my Chicago area COVID-19 tracker, an HTTP call to some JSON data.
Basic elm setup
Detailed instructions for how to write a basic elm project is out of scope for this kind of post, but I want some elm code here for completeness--so that I could fully reproduce this kind of project without having to flip back to the demo project's source code.
I'll start with two basic messages SendString
and UpdateString
that
represent the two directions of information flow into and out of the app.
Msg.elm
module Msg exposing (..)
type Msg = SendString String
| UpdateString String
If you're familiar with Elm ports already, you should be familiar with JSON encoding/decoding in Elm ports. This is out of the scope of what I'm trying to demonstrate, so strings here will be fine, but safely parsing JSON is a best practice and you'll need it for complex data types.
I also want two ports on this elm app, again representing the bidirectional flow of data into and out of this elm app.
Ports.elm
port module Ports exposing (..)
port toReason : String -> Cmd msg
port toElm : (String -> msg) -> Sub msg
And now draw the rest of the owl.
Main.elm
module Main exposing (main)
import Browser
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Http
import Json.Decode
import Models exposing (Model)
import Msg exposing (..)
import Ports
main : Program () Model Msg
main = Browser.element
{ init = init
, subscriptions = subscriptions
, update = update
, view = view
}
------------------------
init : () -> (Model, Cmd Msg)
init _ = ( Models.init
, Cmd.none
)
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch [ Ports.toElm UpdateString
]
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
SendString str -> { model | str = str }
|> \m -> ( m, Ports.toReason m.str )
UpdateString val -> { model | str = val }
|> \m -> (m, Cmd.none)
view : Model -> Html Msg
view model =
div [ class "elm-parent" ]
[ h2 [ class "h2" ] [ text "Controlled by Elm" ]
, input [ placeholder "enter some text"
, type_ "text"
, onInput SendString
, value model.str
] []
]
Again, I'm not going to go through every inch of this--I just want it here for
reference. As you can see, the Messages are wired up in the update
function
and the onInput
event, and the incoming port is wired up in the
subscriptions
.
ReasonML project setup
Next up, initialize a
new BuckleScript project,
and go ahead and install
bs-elm-es6 and add it to the
bs-dependencies
.
Finally, open an Index.re file and expose the module.
open ElmES6;
For completeness
Next, I'm going to define the logic surrounding the ports. I'll compose my ports from these functions.
Explaining this code in detail is out of scope for this post. Basically, all
I'm doing is defining bindings for the basic DOM functionality I need like
getting and setting the value of an input and getting the target
from a
JavaScript event
.
/* setup: simple JS dom interop */
[@bs.val] [@bs.scope "document"]
external getElementById: string => Dom.element = "getElementById";
[@bs.get] external getValue: Dom.element => string = "value";
[@bs.set] external setValue: (Dom.element, string) => unit = "value";
[@bs.set]
external setOnInput: (Dom.element, Dom.event => unit)
=> unit
= "oninput";
[@bs.get]
external getTarget: Dom.event => Dom.element = "target";
/* get input element */
let inputReason: Dom.element = getElementById("input-reason");
Declare the ports as fields in a record
Initializing the elm app requires a type parameter in the form of a record
in which each field represents a port in our elm app. The ElmES6
package
includes two types Elm.sendable('t)
and Elm.subscribable('t)
so that we
can send information to our elm app and subscribe to information from it.
This app is a simple case with just two ports, but I'm going to take the liberty of defining a module for this type so I can move it to a new file later if need be.
module Ports = {
type t = {
toElm: Elm.sendable(string),
toReason: Elm.subscribable(string)
};
};
Get a reference to the elm app
Now that we have our type, we can get our app. This is should look familiar
to anyone who's written elm (v 0.19) ports in JavaScript. The init
function
takes a record which has a single field node
of type Dom.element
.
/* get app */
let app: Elm.app(Ports.t) =
Elm.Main.init({ node: getElementById("elm-target") });
The result is an Elm.app
that gives us access to our ports, so let's use
them.
Wiring up the events
This looks like a lot, but all we're doing is taking the Dom.element
named
inputReason
and settings its oninput
event to a function of a Dom.event
.
The app
we got earlier has a member called ports
(just like in
elm-to-JavaScript ports), and the ElmES6
package has a send
binding, so
we send
event.target.value
, just like we would in JavaScript.
inputReason
-> setOnInput(event => app.ports.toElm
-> Elm.send(event
-> getTarget
-> getValue));
This next one is a little easier to follow. Here, I'm using the
subscribe
binding to set the value of inputReason
whenever our elm app
sends a value through the port.
app.ports.toReason
-> Elm.subscribe(str => setValue(inputReason, str));
Now compile to get Index.bs.js.
Put it all together in the HTML markup
Now all that's left to do is to put it all together in our HTML markup.
<div class="div-reason-demo">
<h2 class="h2">Controlled by ReasonML</h2>
<input class="input" id="input-reason"
placeholder="enter some text" type="text" />
</div>
<div id="elm-target"></div>
</div><!--end container div-->
<script src="scripts/elm/index.js"></script>
<script src="scripts/reason/src/Index.bs.js" type="module"></script>
This gives us everything our app is expecting: 1) an "input-reason" text box, 2) an "elm-target" div, and 3) references to our scripts.
That finishes our project! Again, a completed example can be found here, and full source here.
Let me know if you have any questions!
I write to learn, so I welcome your constructive criticism. Report issues on GitLab.