I’ve been enjoying developing with Elm for a while. In case you’re not familiar with Elm, it’s a strongly-, statically-typed language that compiles to JavaScript. The developer experience is fantastic, largely due to the awesome compiler.
Thanks to Webpacker, it’s now straightforward to integrate Elm into a Rails app. I recently worked on my first Rails + Webpacker + Elm app and I’ll talk about a few patterns that came out of the project.
Getting Started
From the docs:
“Webpacker makes it easy to use the JavaScript pre-processor and bundler Webpack 3.x.x+ to manage application-like JavaScript in Rails”
I won’t duplicate the docs here, but there are handy task commands for getting up and running with Elm.
Elm on Rails
One of the great things about Rails is that it provides clear patterns of how to
organize your code in the project. Webpacker adds the idea of “packs” which are
entry points for webpack. These live under app/javascript/packs
by default. Our
application isn’t an SPA; instead, we have a sprinkling of Elm components
(or widgets) throughout the app, generally one per page. So we have a single
pack per component. Typically these consist of a single line:
// app/javascript/packs/my_component.js
import "my_component";
where my_component
is a directory under app/javascript
containing the code
for the component and an index.js
file, which webpack will load by default.
The pack is included on a page with Webpacker’s javascript_pack_tag
helper.
Typically our components exist on a single page, so we call the pack tag helper
in the relevant view. To keep control over where on the page the actual
script
tag is rendered, we make use of Rails’ content_for
method.
In our view:
<!-- app/views/products/show.html.erb -->
<% content_for :tail_scripts do %>
<%= javascript_pack_tag "my_component" %>
<% end %>
and in our layout, at the end of our body
tag:
<!-- app/views/layouts/application.html.erb --!>
<html>
<head>
<!-- head content --!>
</head>
<body>
<!-- content --!>
<%= yield :tail_scripts %>
</body>
</html>
In the simplest case the index.js
for my_component
looks something like
this:
// app/javascript/my_component/index.js
import Elm from './elm/Main'
document.addEventListener('DOMContentLoaded', () => {
const containerId = "my-elm-container";
const target = document.getElementById(containerId);
Elm.Main.embed(target);
})
This embeds our Elm component in a DOM element, rendered by Rails, on the page.
Our Elm code lives under app/javascript/my_component/elm/
, and our entry
point within this directory is Main.elm
.
Interacting with Elm from the outside
Mostly our Elm components don’t act in isolation and require some context, either from the current page, or higher level global config.
Flags
A scenario that frequently occurred was the requirement to pass some initial state
(or config) from the server (Rails) to our Elm component. The pattern that we used
to achieve this was to set data attributes on the container which we render in our
Rails view, read these in our component entry JavaScript (i.e. the file at
app/javascript/my_component/index.js
we showed above), and pass the values to
Elm as flags.
Let’s say we wanted to pass in a name of some kind, which we have in an
instance variable @name
in Rails. In our Rails view this would look like:
<!-- app/views/products/show.html.erb -->
<%= content_tag :div, nil, id: "my-elm-container", data: { name: @name } %>
We can now update our component entry JavaScript to read the data attribute and pass it into our Elm component as a flag:
// app/javascript/my_component/index.js
import Elm from './elm/Main';
document.addEventListener('DOMContentLoaded', () => {
const containerId = "my-elm-container";
const target = document.getElementById(containerId);
const name = target.getAttribute("data-name");
Elm.Main.embed(target, {
name: name,
});
})
For more general config (e.g. an API host shared across multiple Elm
components), we could make this available in a meta
tag within Rails’
application layout and pass values in as needed to our Elm component:
<!-- app/views/layouts/application.html.erb --!>
<html>
<head>
<!-- head content --!>
<meta name="apiRoot" value="https://api.example.com">
</head>
<body>
<!-- content --!>
</body>
</html>
With an initial value available in a meta
tag, we now write a function to
retrieve the value:
// app/javascript/meta-tag-bootstrap.js
export default function loadMetaTagData(name) {
const element = document.querySelectorAll(`meta[name=${name}]`)[0];
if (typeof element !== "undefined") {
return element.getAttribute("value");
} else {
return null;
}
}
Finally, in our JavaScript file that runs the Elm application, we can import
the function and retrieve the value from the meta
tag based on its name
attribute:
// app/javascript/my_component/index.js
import Elm from './elm/Main';
import loadMetaTagData from "../meta-tag-bootstrap";
document.addEventListener('DOMContentLoaded', () => {
const globalConfig = {
api_root: loadMetaTagData("apiRoot")
};
const containerId = "my-elm-container";
const target = document.getElementById(containerId);
const name = target.getAttribute("data-name");
Elm.Main.embed(target, Object.assign(globalConfig, {
name: name,
}));
});
Ports
Because we’re not working with a single page app, but rather a series of self- contained Elm components, it was sometimes necessary to communicate changes within Elm to the outside world, and vice versa. This might be be as simple as reflecting a change made in Elm elsewhere on the page, or perhaps triggering some vanilla JavaScript. The answer here is Elm ports.
Data out
In this example, we broadcast a change to a quantity value over a port and subscribe to it in our JavaScript code.
In our Elm code we have a function which takes our Model
and an Int
, and
returns a tuple ( Model, Cmd Msg )
. This becomes the return tuple from our
update
function.
-- app/javascript/my_component/elm/Main.elm
updateQuantity : Model -> Int -> ( Model, Cmd Msg )
updateQuantity model quantity =
( { model | quantity = quantity }, quantityChanged quantity )
Where quantityChanged
is defined as a port which sends an integer out to
JavaScript:
-- app/javascript/my_component/elm/Main.elm
port quantityChanged : Int -> Cmd msg
Note that the module where we define our port(s) must be specified as a port
module
.
Then in our JavaScript we subscribe to the port and receive the sent value:
// app/javascript/my_component/index.js
// ...
const component = Elm.Main.embed(target, Object.assign(globalConfig, {
name: name,
}));
component.ports.quantityChanged.subscribe((newQuantity) => {
console.log(`Quantity changed to: ${newQuantity}`);
});
// ...
Data in
For completeness lets say we also want to be able to set an absolute quantity from JavaScript. In this case let’s add a reset button outside of our Elm code, which when clicked sends in a quantity of 0.
Our inbound port receives an Int
and is defined like this:
-- app/javascript/my_component/elm/Main.elm
port setQuantity : (Int -> msg) -> Sub msg
In order to recieve messages from this port we subscribe to it and wire it up with our app:
-- app/javascript/my_component/elm/Main.elm
subscriptions : Model -> Sub Msg
subscriptions model =
setQuantity SetQuantity
main : Program Flags Model Msg
main =
Html.programWithFlags
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
The subscription maps our port to a Msg
which we will handle in our update
function, just like any other Msg
. The Msg
is defined as SetQuantity Int
and
the relevant branch in our update
looks like this:
-- app/javascript/my_component/elm/Main.elm
SetQuantity quantity ->
updateQuantity model <| max 0 quantity
This contains a guard to ensure that our quantity isn’t ever < 0. There are
more elegant ways to handle this, for example a custom Quantity
type, which
are outside of the scope of this article. See our post on avoiding primitives
in Elm for more on leveraging Elm’s type system.
In our JavaScript code we can send a value to Elm over the port like this:
component.ports.setQuantity.send(0);
Sending more complex data over ports
If you find yourself needing to send more complex data types over ports you’ll need to encode as JSON on the way in and decode on the way out. Elm has support for decoding and encoding JSON in the standard library. For a more in depth look see our posts on Decoding JSON Structures with Elm and Bridging Elm and JavaScript with Ports.
Wrapping Up
That just about wraps up the basics of how we integrated Elm components into our Rails app. It feels like a great choice by the Rails team to embrace Webpacker, and means that we’ve gained a bunch of flexibility around the choices we can make for front-end development with Rails.
If you want to play around with any of the examples, a full example Rails app is available on GitHub.