Let me clarify my current thinking on Hologram’s JS interop direction.
@Eiji - your examples are really helpful and show how high-level Hologram modules for abstracting JS Web APIs could work (like Hologram.DOM.query_selector, Hologram.DOM.add_event_listener, etc.). That’s a compelling proposition and we may very well go that way once simpler primitives are implemented and test themselves in battle.
But for now, I’m focusing on the lower-level foundation - just enabling basic interfacing between the Elixir world and JS world. The goal is simple: allow calling JS code from Hologram and vice versa, something more primitive that those higher-level APIs could be built on top of - something that enables calling Web APIs or interfacing with existing JS code like in the example described by @olivermt.
I’ve been analyzing different approaches to JS interop across languages and frameworks and found three main categories that could apply to Hologram.
1. Messaga passing
The first approach is message passing, like Elm’s ports (as mentioned by @kingdomcoder) or Phoenix LiveView hooks.
In Elm, you define ports for sending data out and subscribing to data coming in:
port sendData : String -> Cmd msg
port receiveData : (String -> msg) -> Sub msg
Then in JavaScript, you connect to these ports:
// Listen to data sent from Elm
app.ports.sendData.subscribe(function(data) {
console.log("Received from Elm:", data);
});
// Send data to Elm
app.ports.receiveData.send("Hello from JS")
LiveView uses a similar pattern - it communicates with the JS world through hooks.
Pros: Clear boundaries between language worlds, explicit control over data flow
Cons: Communication overhead, forces async patterns even for simple operations
2. Foreign Function Interface (FFI)
The second is Foreign Function Interface (FFI), like Gleam uses. You declare external functions that map to JavaScript:
@external(javascript, "./my_js_file.mjs", "greet")
pub fn greet(name: String) -> String
With corresponding JavaScript:
export function greet(name) {
return `Hello, ${name}!`;
}
This pattern is actually very common - most bindings with C/C++ libraries use this approach, and even Rust NAPI works similarly where you declare foreign functions that bridge between languages. For Hologram, this could look like declaring JS functions directly in Elixir modules.
Pros: Direct function calls, minimal overhead
Cons: Manual binding maintenance, potential runtime errors
3. Native compilation/interop
The third approach is native compilation/interop, like Kotlin/JS. Since the code compiles to JavaScript, you can directly interface with JS:
external fun alert(message: String)
external val console: Console
fun main() {
alert("Hello from Kotlin!")
console.log("Direct JS access")
}
Pros: Natural integration, full ecosystem access
Cons: Compilation complexity, language semantic mismatches
For Hologram, interestingly, the third approach doesn’t translate that well. While Hologram does compile to JavaScript, it uses its own client-side Elixir runtime rather than generating idiomatic JavaScript. This means we’re running Elixir semantics on top of JavaScript, and the different syntax and data types between JavaScript and Elixir (atoms, tuples, pattern matching, immutable data structures) don’t have direct JavaScript equivalents.
What do you think? Which approach resonates most with your use cases?