Writing Elm Ports in ReScript - 0.3
This is an update to a previous article following a breaking change in the res-elm
binding. In short, the init
function has been broken up into init
and initWithOptions
to allow for Elm initialization flags and Elm web applications.
Recently I've published an npm package called res-elm 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 ReScript.
The Goal: shared control between ReScript 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 ReScript app and the Elm app communicating in real time. You can find such an app in this live demo.
Take a moment to play around with the two text boxes. The first one lives in ReScriptland, but on its input event, ReScript sends its content into the Elm app. The second lives in Elmland, but on its input event, sends its input to the ReScript 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 toReScript : 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 --subscribe to incoming string
]
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
SendString str -> { model | str = str }
|> \m -> ( m, Ports.toReScript m.str ) --call outgoing port
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
.
ReScript project setup
Next up, initialize a new ReScript project, and go ahead and install res-elm and add it to the bs-dependencies
.
Finally, open an Index.res file and expose the module.
open Elm;
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 */
@val @scope("document")
external getElementById: string => Dom.element = "getElementById"
@get external getValue: Dom.element => string = "value"
@set external setValue: (Dom.element, string) => unit = "value"
@set external setOnInput: (Dom.element, Dom.event => unit) => unit = "oninput"
@get external getTarget: Dom.event => Dom.element = "target"
/* get input element */
let inputReScript: Dom.element = getElementById("input-rescript")
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 res-elm
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>,
toReScript: 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: Some(getElementById("elm-target")),
flags: None
});
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 inputReScript
and setting 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 Elm
package has a send
binding, so we send
event.target
.value
, just like we would in JavaScript.
inputReScript
-> 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 inputReScript
whenever our elm app sends a value through the port.
app.ports.toReScript -> Elm.subscribe(str => setValue(inputReScript, 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-rescript-demo">
<h2 class="h2">Controlled by ReScript</h2>
<input class="input" id="input-rescript"
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/rescript/src/Index.bs.js" type="module"></script>
This gives us everything our app is expecting: 1) an "input-rescript" text box, 2) an "elm-target" div, and 3) references to our scripts.
That finishes our project! Again, a completed example can be found on my demo site, and full source here. Let me know if you have any questions!