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!