Using Elm Ports to Safely Talk to a JavaScript Charting Library
Learn how Elm ports let you safely exchange data with JavaScript libraries like Chart.js without sacrificing Elm’s guarantee of no runtime exceptions.
04 Nov 2025, 19:11 UTC

Problem: Chart updates need JavaScript but Elm stays pure
You want to draw interactive charts with a mature library like Chart.js, but Elm’s core must remain free of impure JavaScript to keep its guarantee of no runtime exceptions. Directly calling JS from Elm would break that guarantee, while keeping the chart logic entirely in Elm would mean re‑implementing a complex visualization library.
How Elm ports work
Elm treats every side‑effect as a Cmd that flows out through a port. An outbound port is declared with port sendData : Value -> Cmd msg and the Elm runtime exposes it on JavaScript as app.ports.sendData.subscribe. Inbound ports work the opposite way: port receiveMsg : (Value -> msg) -> Sub msg appears as app.ports.receiveMsg.send on the JS side. All data crossing the port is automatically serialized to/deserialized from JSON, so the Elm core never sees raw JavaScript objects.
Worked example: sending data to Chart.js and receiving clicks
Below is a minimal Elm program that:
- Encodes a simple dataset as JSON and sends it through an outbound port.
- Receives click events from the chart through an inbound port and updates the model.
module Main exposing (main)
import Browser
import Html exposing (Html, div)
import Json.Encode as Encode
import Json.Decode as Decode exposing (Decoder)
-- MODEL
type alias Model = {
lastClick : Maybe String
}
init : Model
init = {
lastClick = Nothing
}
-- UPDATE
type Msg = ChartClick String
update : Msg -> Model -> Model
update msg model =
case msg of
ChartClick id ->
{ model | lastClick = Just id }
-- VIEW
view : Model -> Html Msg
view model =
div [] []
-- PORTS
port sendData : Encode.Value -> Cmd msg
port receiveClick : (String -> msg) -> Sub msg
-- MAIN
main : Program () Model Msg
main =
Browser.sandbox
{ init = init
, view = view
, update = update
}
The accompanying HTML loads the compiled Elm app, creates a Chart.js instance, and wires the ports:
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<div id="elm-root"></div>
<canvas id="myChart" width="400" height="200"></canvas>
<script>
const elmApp = Elm.Main.init({ node: document.getElementById('elm-root') });
const ctx = document.getElementById('myChart').getContext('2d');
const chart = new Chart(ctx, {
type: 'bar',
data: { labels: [], datasets: [{ label: 'Values', data: [] }] },
options: { onClick: (evt) => {
const points = chart.getElementsAtEventForMode(evt, 'nearest', { intersect: true }, true);
if (points.length) {
const index = points[0].index;
elmApp.ports.receiveClick.send(chart.data.labels[index]);
}
} }
});
// outbound: Elm -> JS
elmApp.ports.sendData.subscribe(data => {
const obj = JSON.parse(data);
chart.data.labels = obj.labels;
chart.data.datasets[0].data = obj.values;
chart.update();
});
</script>
</body>
</html>
When the Elm runtime sends a JSON payload via sendData, the JavaScript subscriber parses it and updates the Chart.js dataset. When a user clicks a bar, the chart’s click handler calls receiveClick.send, which Elm decodes as a String and turns into a ChartClick message.
Trade‑offs and limitations
- Manual marshalling: Every value crossing a port must be encoded to JSON on the Elm side and decoded on the JS side (or vice‑versa). For deeply nested records this can become tedious and error‑prone.
- Loss of Elm guarantees on the JS side: If the JSON does not match the expected shape, the JavaScript code may throw, but the Elm runtime itself will stay safe—no Elm exception will propagate.
- Performance: JSON serialization adds overhead compared to native Elm values, though for typical UI interactions the cost is negligible.
To check that the integration works, open the browser console and verify two things:
- No red error messages appear from Elm (the runtime’s
Errorconsole stays empty). - When you change the dataset in Elm (e.g., edit the
initmodel to send different labels/values) the chart updates accordingly, and clicking a bar logs the clicked label in the console via a temporaryconsole.login the JS click handler.
Actionable closing
Ports are the idiomatic way to bring any JavaScript library into an Elm application while preserving Elm’s core purity and its “no runtime exceptions” promise. Start by identifying the smallest piece of data you need to send or receive, declare a matching port, and handle the JSON marshalling explicitly. As your interop needs grow, you can encapsulate the port logic in a dedicated Elm module, keeping the rest of your codebase free from JavaScript concerns.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.