Using Elm within VueJS
Lately I have been using VueJS a lot. It’s an easy framework to learn and gets results quickly. There is an optional Typescript package to get some much needed typesafety. Overall, I’m pretty happy with it. However, the templates in Vue are where the typesafety breaks down. Typescript just has no way of knowing what’s happening in there, whereas it does have support for JSX/TXS and will supply you with type information in e.g. React components.
However, I have dabbled in Elm before, a language that compiles to javascript and gives no runtime exceptions. It would completely fix all the typing issues and more.
Its founder suggests to start small with Elm in projects. So, no complete overhauls but a fix a buggy cog somewhere with Elm. I wondered whether I could use it within VueJS…
I set up a new Vue project with the Vue cli
# install vue-cli
$ npm install --global vue-cli
# create a new project using the "webpack" template
$ vue init webpack elm-in-vue
# install dependencies and go!
$ cd elm-in-vue
$ npm install
When this is finished, you can run npm run dev
to run a webserver with your new Vue project.
Now, we’re going to add Elm. First, we install elm
and elm-webpack-loader
. The elm package has all the tooling for Elm whereas we need the webpack loader so we can bundle the Elm code compiled to javascript in our app.
npm install --save elm elm-webpack-loader
The webpack config also needs to be made aware of the Elm loader.
Add the following lines to the rules
in your webpack configuration. This basically tells webpack to use the Elm compiler for .elm
// ./build/webpack.base.conf.js
{
test: /\.elm$/,
exclude: [/elm-stuff/, /node_modules/],
use: {
loader: 'elm-webpack-loader',
options: {}
}
},
The Elm component
So, now we can get to integrating Elm into Vue. First, I’m creating a small Elm program called Counter. If you’re familiar with Elm it should be easy to understand. We use a port module so we can have some interop with javascript. It has a model consisting of one integer and it renders two buttons to increment/decrement and a counter. Fairly basic.
-- ./src/Counter.elm
port module Main exposing (..)
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)
main =
Html.program { init = init, view = view, update = update, subscriptions = subscriptions }
-- MODEL
type alias Model =
Int
init : ( Model, Cmd Msg )
init =
( 0, Cmd.none )
-- UPDATE
type Msg
= Increment
| Decrement
| Multiply Int
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Increment ->
( model + 1, watchCounter (toString msg) )
Decrement ->
( model - 1, watchCounter (toString msg) )
Multiply val ->
( model * val, watchCounter (toString msg) )
port counter : (Int -> msg) -> Sub msg
port watchCounter : String -> Cmd msg
subscriptions : Model -> Sub Msg
subscriptions model =
counter Multiply
-- VIEW
view : Model -> Html Msg
view model =
div []
[ button [ onClick Decrement ] [ text "-" ]
, div [] [ text (toString model) ]
, button [ onClick Increment ] [ text "+" ]
]
The Elm-Vue bridge
Our goal is to render this program in a VueJs component. To do so, we create a wrapper component. It is function that takes an Elm module and returns an object for Vue to consume. The object takes two props, ports
for Elm/javascript interop and flags
, to set the initial state the program. Once the object’s lifecycle hook mounted
gets called, we find the DOM node and tell Elm to embed it, if any ports are set we use those to set up a bridge between Elm and javascript.
// ./src/elm.js
module.exports = function (elm) {
return {
props: {
ports: {
type: Function,
required: false
},
flags: {
type: Object,
required: false
}
},
render: function (createElement, _context) {
return createElement('div')
},
mounted: function () {
var node = this.$el
var app = elm.embed(node, this.$props.flags)
if (this.$props.ports) {
this.$props.ports(app.ports);
}
}
}
}
Now, how do we call this from Vue?
We are going to adapt the App.vue
file that was generated for us by the vue-cli tool.
<!-- ./src/App.vue -->
<template>
<div id="app">
<img src="./assets/logo.png">
<Counter></Counter>
</div>
</template>
<script>
import Hello from './components/Hello'
import * as ElmComponent from './elm'
export default {
name: 'app',
components: {
'Counter': ElmComponent(require('./Counter.elm').Main)
}
}
</script>
You can see that we import the ElmComponent function and call it with our Counter.elm code. Webpack sees the extension and will compile the code. The call to Main
returns the Elm app. We register it locally under Counter so in the template we can call it as such.
If you run ```npm run dev` now it should show the counter underneath the Vue logo!
Calling Elm from javascript
We can use ports to send messages to the Elm program. We setup the ports using the ports
prop when calling the component. This sets the ports property on our Vue object. Now we can use normal Vue syntax in the button to call into the Elm program and multiply the model’s current value by 10.
The counter
property of ports was opened as port in the Elm code above. Note how it was defined to only accept integers, if we send anything else it will give an error message. See here on how the mapping between Elm and javascript types is defined.
<template>
<div id="app">
<img src="./assets/logo.png">
<Counter :ports="setupPorts"></Counter>
<button @click="ports.counter.send(10)">Multiply by 10</button>
</div>
</template>
<script>
import Hello from './components/Hello'
import * as ElmComponent from './elm'
export default {
name: 'app',
components: {
'Counter': ElmComponent(require('./Counter.elm').Main)
},
methods: {
setupPorts: function (ports) {
ports.watchCounter.subscribe(function (message) {
console.log(message)
})
this.ports = ports
}
}
}
</script>
We also can listen to changes in the Elm model, and notify Vue of those changes. This is done with the watchCounter port we set in the Elm code. VueJs can subscribe to this port and will log any messages being passed.
This is all that is necessary to have an Elm program in your Vue app. See this repo for all the code. Also, the React equivalent was very helpful in setting this up.
Photo by Jan’s Archive on Unsplash