One of the most common questions I get about Elm is: “How do I integrate it with existing JavaScript ecosystems?” While Elm’s isolation is a strength, real-world projects often require working with external libraries, APIs, or UI components. Doing incremental migration is also the recommended way to introduce Elm, and luckily there are may ways to accomplish this.
Today, I’ll show you how to combine two powerful technologies:
- Elm Ports: The official way to communicate between Elm and JavaScript
- Web Components: Standard, framework-agnostic UI components
This combination gives us the best of both worlds: Elm’s type safety and predictable architecture alongside the reusability and interoperability of Web Components. Let’s dive in!
What we’re building Link to heading
You know what? Let’s embed it:
What Are Elm Ports? Link to heading
Ports are Elm’s sanctioned escape hatch to JavaScript. Or put another way: They facilitate treating JavaScript as mere IO device. Specifically they allow your Elm application to:
- Send data out to JavaScript (outgoing port)
- Receive data from JavaScript (incoming port)
Think of ports as message channels between Elm’s pure world and JavaScript’s wild west.
What Are Web Components? Link to heading
Web Components are a set of standardized browser APIs that allow you to create reusable, encapsulated components using plain JavaScript, HTML, and CSS. The key technologies include:
- Custom Elements: Create your own HTML tags
- Shadow DOM: Encapsulated DOM and styles
- HTML Templates: Reusable markup structures
Once defined, Web Components work in any framework (or no framework) - making them ideal for sharing UI elements across different projects.
Why Combine Them? Link to heading
- Use specialized UI libraries not available in Elm
- Share components across projects using different frameworks
- Gradually migrate existing applications to Elm
- Integrate third-party tools (maps, charts, rich text editors)
The Example Project: A Color Picker Link to heading
Let’s build a simple example: an Elm application that uses a Web Component color picker. We’ll:
- Create a simple Elm app that displays the selected color
- Integrate a color-picker Web Component
- Use ports to communicate between them
Step 1: Setting Up the Elm Application Link to heading
First, let’s create our core Elm application:
port module Main exposing (main)
import Browser
import Html exposing (..)
import Html.Attributes exposing (..)
import Json.Decode as Decode
import Json.Encode as Encode
-- PORTS
port sendColor : String -> Cmd msg
port receiveColor : (String -> msg) -> Sub msg
-- MODEL
type alias Model =
{ currentColor : String
}
init : () -> ( Model, Cmd Msg )
init _ =
( { currentColor = "#3366ff" }
, sendColor "#3366ff" -- Initialize the color picker with our default
)
-- UPDATE
type Msg
= ColorChanged String
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ColorChanged newColor ->
( { model | currentColor = newColor }
, Cmd.none
)
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions _ =
receiveColor ColorChanged
-- VIEW
view : Model -> Html Msg
view model =
div [ style "padding" "2rem", style "font-family" "system-ui, sans-serif" ]
[ h1 [] [ text "Elm + Web Components" ]
, div []
[ p [] [ text "Selected color: ", strong [] [ text model.currentColor ] ]
, div
[ style "width" "100px"
, style "height" "100px"
, style "background-color" model.currentColor
, style "margin" "1rem 0"
, style "border-radius" "4px"
]
[]
, div []
[ -- Our Web Component will be placed here
node "color-picker"
[ attribute "current-color" model.currentColor
]
[]
]
]
]
-- MAIN
main : Program () Model Msg
main =
Browser.element
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
In this Elm code, we’ve:
- Defined two ports:
sendColor
to send the current color to JavaScript, andreceiveColor
to receive color changes - Used
node
to insert our custom element (which we’ll create next) - Set up the appropriate model, update, and subscription functions
Step 2: The JavaScript Glue Link to heading
Next, we need to initialize Elm and wire up our ports:
// index.js
// Init Elm app
const app = Elm.Main.init({ node: document.querySelector("main") });
// Define our color-picker Web Component
class ColorPicker extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this._app = null; // Will store reference to Elm app
this._boundHandler = this._handleColorChange.bind(this);
this.render();
}
// Connect to DOM - add event listeners
connectedCallback() {
this._attachEventListeners();
}
// Clean up when removed from DOM
disconnectedCallback() {
this._detachEventListeners();
}
// Add event listeners to the input
_attachEventListeners() {
const input = this.shadowRoot.querySelector("input");
if (input) {
input.addEventListener("input", this._boundHandler);
}
}
// Remove event listeners from the input
_detachEventListeners() {
const input = this.shadowRoot.querySelector("input");
if (input) {
input.removeEventListener("input", this._boundHandler);
}
}
// Handle color changes from the input
_handleColorChange(event) {
const newColor = event.target.value;
this.setAttribute("current-color", newColor);
// Send the color back to Elm
if (this._app && this._app.ports && this._app.ports.receiveColor) {
this._app.ports.receiveColor.send(newColor);
}
}
// Store reference to Elm app
setApp(app) {
this._app = app;
}
// Watch for attribute changes
static get observedAttributes() {
return ["current-color"];
}
// Handle attribute changes
attributeChangedCallback(name, oldValue, newValue) {
if (name === "current-color" && oldValue !== newValue) {
// Update the input value directly if possible
const input = this.shadowRoot.querySelector("input");
if (input && input.value !== newValue) {
input.value = newValue;
} else {
// Otherwise re-render
this.render();
this._attachEventListeners();
}
}
}
get currentColor() {
return this.getAttribute("current-color") || "#000000";
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: inherit;
}
.picker-container {
display: flex;
align-items: center;
gap: 8px;
}
label {
font-weight: bold;
}
input[type="color"] {
width: 50px;
height: 30px;
border: none;
border-radius: 4px;
}
</style>
<div class="picker-container">
<label for="color-input">Pick a color:</label>
<input type="color" id="color-input" value="${this.currentColor}">
</div>
`;
}
}
// Register the Web Component
customElements.define("color-picker", ColorPicker);
// Set up communication from Elm to the Web Component
app.ports.sendColor.subscribe((color) => {
// Find our color picker element
const picker = document.querySelector("color-picker");
if (picker) {
// Give the Web Component a reference to the Elm app
if (!picker._app) {
picker.setApp(app);
}
// Update the color
picker.setAttribute("current-color", color);
}
});
This update ensures that the JavaScript code in the blog post matches exactly with the code from foo.html
.
Step 3: HTML Setup Link to heading
Finally, we need a simple HTML file to bring it all together:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Elm + Web Components</title>
<script src="index.js"></script>
<script src="elm.js"></script>
</head>
<body>
<div id="elm-app"></div>
</body>
</html>
How It Works Link to heading
The data flow in our application follows this pattern:
Elm → JavaScript:
- Elm sends the current color through the
sendColor
port - JavaScript receives this value and updates the Web Component’s attribute
- Elm sends the current color through the
Web Component → JavaScript → Elm:
- User interacts with the color picker
- Web Component fires an event
- The event handler sends the new color to Elm via the
receiveColor
port - Elm updates its model with the new color
This clean separation maintains Elm’s purity while leveraging the native capabilities of the web platform.
Real-World Considerations Link to heading
1. More Complex Data Link to heading
For our simple example, we’re just passing strings. For complex data, you’ll need to encode/decode JSON:
-- In Elm
port sendComplexData : Encode.Value -> Cmd msg
port receiveComplexData : (Decode.Value -> msg) -> Sub msg
-- Using it
sendComplexData (Encode.object
[ ("color", Encode.string model.color)
, ("opacity", Encode.float model.opacity)
, ("name", Encode.string model.name)
])
// In JavaScript
app.ports.sendComplexData.subscribe((data) => {
console.log(data.color, data.opacity, data.name);
});
2. Error Handling Link to heading
When receiving data from JavaScript, always be prepared for unexpected values:
type Msg
= GotColorData (Result Decode.Error ColorData)
subscriptions : Model -> Sub Msg
subscriptions _ =
receiveComplexData (decodeColorData >> GotColorData)
decodeColorData : Decode.Value -> Result Decode.Error ColorData
decodeColorData value =
Decode.decodeValue colorDataDecoder value
3. Multiple Components Link to heading
With multiple Web Components, maintain a clear naming convention for your ports:
port sendColorPickerData : Encode.Value -> Cmd msg
port receiveColorPickerData : (Decode.Value -> msg) -> Sub msg
port sendMapData : Encode.Value -> Cmd msg
port receiveMapData : (Decode.Value -> msg) -> Sub msg
How This Relates to Clean Architecture Link to heading
This ports + Web Components pattern aligns perfectly with Clean Architecture principles:
Separation of Concerns:
- Elm handles application logic
- Web Components handle specialized UI rendering
- Ports define clear boundaries between systems
Dependency Rule:
- Core business logic in Elm doesn’t depend on the Web Component details
- The outer layer (JavaScript) depends on the inner layer (Elm), not vice versa
Testability:
- Elm code can be tested without Web Components
- Ports can be mocked for testing
- Web Components can be tested in isolation
This approach gives us the best of both worlds:
- Elm’s strengths: Type safety, immutability, and predictable state management
- Web Components’ strengths: Standards-based, reusable UI components
Conclusion Link to heading
The combination of Elm ports and Web Components offers a powerful way to build robust applications while still leveraging the best tools from the broader web ecosystem. This approach maintains the benefits of Elm’s architecture while embracing the interoperability of web standards.
By keeping your core application logic in Elm and using Web Components only for specialized UI needs, you get a clean, maintainable architecture with clear boundaries.
Have you used Elm ports or Web Components in your projects? I’d love to hear about your experiences, feel free to add feedback using my new feedback tool, Feedback.one.
Resources Link to heading
Note: This blog post is intended for developers with basic knowledge of both Elm and Web Components. If you’re new to Elm, check out the official guide first.