Time for Hologram <> JavaScript Interop - What Would You Like to See?

I’d prefer to avoid basing architectural decisions on polls though :wink: Not that I don’t like polls, but I’m trying to provoke a deeper discussion here and am more interested in understanding the deeper trade-offs for Hologram’s specific context.

I’m honestly on the fence here and looking for someone to convince me why one of these approaches is genuinely better than the others. I don’t really care if something is familiar - what I care about is the best DX in general. Sure, familiarity might be part of DX, but there are many other factors like ease of use and ease of maintenance.

4 Likes

I want the smallest possible surface of integration that gives the largest possible space of custimization basically. Only option 1 has that within feasible timeframe imo.

Familiarity with LV is a bonus, but I meant it more as a pattern that works just fine.

Since entire JavaScript API would use such FFI interface under the hood then it’s for entire API - that’s exactly what I mean. You say about it like a single try/catch for FFI interface, but look that it would be called very, very often even for a single client and it’s definitely not like that the app itself would use just single FFI interface call - it would happen every time you execute a getter/setter/function in JavaScript or at best it’s one call for the small JS snippet.

If it’s not a case then it should become one as otherwise it’s a bad pattern that would be way too overused. For FFI we would either need Rust NIF-like solution that is much more safe (maybe TypeScript?) or let it crash the entire VM as it happens with every other NIF.

I have a huge respect to core developers and I don’t believe that I would ever deliver a better implementation i.e. NIF-like solution that is completely safe and fast at the same time while other NIFs crash Erlang VM. Maybe there would be some expert of experts that could do that, but I would say that’s definitely out of scope for a typical forum reader. Of course it would be amazing if it would happen, but it’s not something that every developer could simply plan to implement or just add to backlog. Maybe I trust core developers too much, but I bet that the Erlang VM crashes happen for very solid reasons and it’s very hard (maybe overcomplicated) to avoid it, it’s hard to make a cross-platform solution or it’s even against the Erlang VM nature at all.

I would say we should focus on much safer ways like the one I suggested. The more structs we implement the more getters/setters/apply calls we would be able to implement. This way other developers could implement DOM API as long as all the necessary structs used for it could be translated in said getters/setters/apply calls.

This can be implemented in many, maaany… different ways. Some examples:

1. Message passing

In Elixir:

defmodule MyComponent do
  use Hologram.Component

  port :just_draw_it

  def action(:my_action, _params, component) do
    send_port_message(:just_draw_it, ["my_canvas", 100, 200, [{34, 223}, {13, 93}]])
    component
  end
end

In JS:

import * from Chart;

window.Hologram.ports.just_draw_it = (element_id, width, height, data_points) => {
   const element = document.getElementById(element_id)
   
   const chart = new Chart(element, width, height)
   chart.draw(data_points)
}

Message passing is async, so we don’t expect a result from just_draw_it function.
Also, the port definitions and calls could as well look more like function calls.

2. Foreign Function Interface (FFI)

In Elixir:

defjs greet(name), "./my_js_file.mjs", "greet"

In JS:

export function greet(name) {
    return `Hello, ${name}!`;
}

or we can go even further, by separating interop functionality to specialized interop modules like here:

defmodule MyJSInterop do
  use Hologram.JS

  js_dep :local, "./my_js_file.mjs", "greet"
  js_dep, :npm, "some_lib", "my_fun"

  defjs greet(name), “greet” 

  (…)
end

potentially even providing specialized functions to handle the Elixir/JS semantic mismatch:

  def just_draw_it(element_id, width, height, data_points) do
    element =
     "document"
      |> js_ref()
      |> js_call("getElementById", [element_id])

    if js_unwrap(element) == nil do
      raise "Element not found"
    end

    chart =
     "Chart"
      |> js_ref()
      |> js_create([element, %{width: width, height: height}])

    chart
    |> js_read("id")
    |> IO.inspect(label: "Chart id")

    js_call(chart, "draw", data_points)

    js_call(chart, "addEventListener", [
      "pointClick",
      fn point -> IO.inspect(point, label: "Point clicked") end
    ])

    chart
    |> js_call("getWidth")
    |> js_unwrap()
    |> IO.inspect(label: "Chart width")

    chart
    |> js_call("getHeight")
    |> js_unwrap()
    |> IO.inspect(label: "Chart height")
  end

However it could also potentially get out of hand, and requires JS knowledge anyway.


JS → Hologram could be similar for both cases, we could simply dispatch a Hologram action or command like this:

In JS:

Hologram.dispatchAction("MyComponent", "my_action", "my_target", params)

Calling Elixir modules wouldn’t make sense, because JS doesn’t know what Elixir modules have been transpiled, so such call should go through pages or components. Hologram sees modules used in pages/components and can include them in the bundle.

A huge :+1: for solution with refs.

That’s not a problem at all. It’s much more better than Hooks. The only thing I would change is to avoid using js_ prefix in function names if possible. I’m in a group of people who prefer alias over imports (especially when importing from many modules) and with such way we would double js naming. Think about JS.js_unwrap("Chart").

1 Like

Thanks for those examples. I like the idea of having the solution be as simple as just writing functions and calling them.

Sounds interesting. If that’s the case, then maybe it would encompass one of the things I like most about #2.

This still feels like the simplest approach to me. It would allow me to just write js functions when I need them and then add a one liner to wire it up in elixir. I wonder if there is another way to cut down on boilerplate / binding maintenance with macro magic or other. Would something like this be possible?

js do
  """
   function greet(name) { 
    return `Hello, ${name}!`;
  }
  """
end

Invoked with:

JS.greet("Bart")

Hmm my first reaction to this is it feels a bit complicated but maybe I’m missing something. It’s not immediate clear to me where the DX win is but it would require learning more syntax that is unique to Hologram. Since JS interop is meant to be an escape hatch, I think the js code should just be js.

Interesting. What do you think of using Hologram.put_action so there’s less surface area to learn? Also, fwiw as a minor thing, I think it’d be good to use snake_case even though it’s not the JS convention.

1 Like

That’s interesting directions to explore for higher-level APIs. But right now I’m really trying to nail down the most primitive building blocks - think more like “how do I call someJsFunction(arg1, arg2) from Elixir and get the result back” rather than “how do I elegantly manipulate the DOM through Elixir-style APIs”. For now, I’m not even sure we should allow DOM manipulation through Elixir at all - we already have the template system for that. Maybe DOM manipulation should just stay in JS directly, and the interop should focus on calling into JS libraries and getting data back. I don’t know.

The main goal here is to create an escape hatch that serves as a permanent foundation - something that unblocks users who need to integrate with existing JS libraries or APIs today, and that will remain as the underlying layer even as we build more elegant abstractions on top.

If someone needs to use Google Maps (like @olivermt’s example), integrate Chart.js, or call into their company’s existing JavaScript codebase, they should be able to do it with basic primitives rather than waiting for me to build high-level abstractions.

This isn’t a temporary solution - it’s the foundational layer. The more elegant solutions you’re describing would be built on top of these same primitives. Something like Hologram.DOM.query_selector examples could be implemented using the low-level interop if we decide we want to allow DOM manipulation outside Hologram template system. And users could even create their own Web API wrappers in pure Elixir using these same building blocks.

Regarding your point about mapping Elixir data types to JS equivalents - while it’s technically possible, I think that’s a dead end for interop. We’d inevitably end up with a leaky abstraction at some point. Plus, what happens when JavaScript or Elixir evolve?

The interop layer should embrace the fact that we’re crossing language boundaries rather than trying to hide it. Let JS be JS, let Elixir be Elixir.

I’d rather have libraries that work and feel like Elixir than trying to pretend we can write JS directly in Elixir.

For example, a Canvas Elixir wrapper could look like this:

"my_element"
|> Canvas.new()
|> Canvas.fill_style("green") 
|> Canvas.fill_rect(10, 10, 150, 100)

Notice this feels like Elixir and hides the canvas context object and other metadata in the Canvas struct.

This code would be easily testable directly in Elixir, because we could have a server (test-only) version of this module that records all ops on the struct (instead of calling JS) and verifies the result.

Otherwise, we’re just pretending we’re using Elixir while trying to map to JS one-to-one, which doesn’t feel like Elixir and forces you to understand JS notions of mutability, objects and references.

There is some overhead, but it’s minimal during normal execution. Modern JavaScript engines are pretty good at optimizing try/catch blocks when no exceptions are thrown. However, in some cases, functions containing try/catch might be less aggressively optimized by the JIT compiler.

The real overhead happens during actual exception handling - stack trace generation and capture, unwinding the call stack, object creation for the Error instance, and searching for appropriate catch handlers.

The thing is, Elixir/Erlang runtime characteristics are one thing, and Hologram’s client-side runtime performance characteristics are another. I’d say we need to be practical here.

For now, there’s no supervision at all on the client side, and I’m not sure we should try to force the UI part into an Elixir process model anyway - JavaScript is single-threaded by nature. We can’t map everything 1-to-1 from Elixir to JS. Even when we eventually have processes ported to JS (for example through Web Workers), it may require special APIs that don’t directly mirror Elixir’s process model. I don’t know yet exactly how it will look, but I want to keep in mind that we can’t assume a perfect 1-to-1 mapping of all Elixir concepts to JavaScript.

So while I deeply respect the “let it crash” philosophy for server-side Elixir, the client-side JavaScript interop boundary might warrant a more defensive approach, at least initially.

Interesting thread, lots of good ideas here. Let me throw in my two cents.

After going through the proposed examples, I’m personally a fan of introducing a small set of primitive functions for interacting with JavaScript — such as the ones already mentioned: js_ref, js_call, js_create or more explicitly js_call_constructor, js_read or alternatively a pair of js_get and js_set for property access. I’m not entirely sure whether the js_ prefix is the best naming convention, but the concept itself feels solid.

Building on the earlier examples, I’d imagine it like this:

defmodule Chart do
  use Hologram.JS.Interop

  def draw(element_id, width, height, data_points) do
    element =
      js_ref("document")
      |> js_call("getElementById", [element_id])

    if is_nil(js_unwrap(element)) do
      # handle case when element is missing
    end

    js_ref("Chart")
    |> js_create([element, %{width: width, height: height}])
    |> js_call("draw", [data_points])
  end
end

I think this kind of low-level API enables several valuable characteristics:

  1. Composability. It gives Elixir code a straightforward, structured way to express JS interop. If Hologram also transpiles macros and custom sigils, this unlocks a wide range of opportunities. For example, developers could implement their own JSX-style sigil that maps HTML to React elements, or a defn macro (like the one from the nx library) that transpiles to TensorFlow.js computational graphs.

  2. Encapsulation. Having granular primitives could encourage developers to isolate JS interop inside dedicated modules, rather than scattering inline snippets across the app. That in turn could lead to Hex packages that wrap JS libraries with clean Elixir APIs.

  3. Flexibility. Low-level primitives are inherently more flexible than the other approaches discussed. They let Elixir compose JS calls while still leaving room for higher-level abstractions (e.g. message-based APIs) to emerge later on. Those higher-level APIs could simply be implemented on top of the primitives once common usage patterns are clearer.

Of course, a fundamental question arises: to what extent can all types and idioms be mapped between Elixir and JS? Probably not 100%. But even without full coverage (runtime errors for few worst-case scenarios), this approach seems more powerful and flexible than a system based solely on messages, ports, or sigils—since at the end of the day, one might still need to get results from the JavaScript side and unwrap them to Elixir types.

That said, certain constructs lend themselves to fairly direct mappings. For example, promises could be mapped reasonably well to Elixir’s Tasks, which offer similar semantics for retrieving asynchronous values. The underlying scheduling differs — JS has cooperative scheduling via its event loop, while Elixir uses preemptive scheduling — but in most cases, when delegating behaviour to JavaScript libraries, the difference shouldn’t be a blocker.

Finally, when it comes to error handling, the js_ primitives could consistently return result tuples such as {:ok, %JS.Ref{}} | {:error, %JS.Error{kind, js_stack, cause}}, keeping things aligned with Elixir conventions.

One more idea: a primitive for importing ESM modules asynchronously:

defmodule Utils do
  def enable_copy_button() do
    import_task = js_import("https://esm.sh/clipboard@2.0.11")
    clipboard_mod = Task.await(import_task) 
    js_call_constructor(clipboard_mod, [".copy-btn", %{text: fn -> "some text to copy" end}])
  end
end

# or as macro that fetches and bundles js at compile-time

defmodule Utils do
  use Hologram.JS.Interop

  js_import "https://esm.sh/clipboard@2.0.11", as: :clipboard_mod

  def enable_copy_button() do
    js_call_constructor(clipboard_mod(), [".copy-btn", %{text: fn -> "some text to copy" end}])
  end
end

Overall, I think a small set of clear building blocks could give both structure and flexibility. Structure, because every interop call would stay explicit, unified and easy to follow in Elixir. Flexibility, because higher-level helpers could still be built on top without losing the low-level power when needed. This also feels like the safest way forward—thin, explicit core now, with room to add higher-level APIs as they emerge.

3 Likes

Completely agree, as already mentioned I’m also not sure for js_* prefix, so let’s see how people would like to write a code if they would prefer alias over import/use:

defmodule Chart do
  alias Hologram.JS

  def draw(element_id, width, height, data_points) do
    element =
      "document"
      |> JS.ref()
      |> JS.call("getElementById", [element_id])

    if is_nil(JS.unwrap(element)) do
      # handle case when element is missing
    end

    "Chart"
    |> JS.ref()
    |> JS.create([element, %{width: width, height: height}])
    |> JS.call("draw", [data_points])
  end
end

I’m not only sure about JS.unwrap/1 … It would be absolutely amazing if we could use unwrapped element in JS API, see:

defmodule Chart do
  alias Hologram.JS

  def draw(element_id, width, height, data_points) do
    element =
      "document"
      |> JS.ref()
      # already unwrapped after this call
      |> JS.call("getElementById", [element_id])

    if is_nil(element) do
      # handle case when element is missing
    end

    "Chart"
    |> JS.ref()
    |> JS.create([element, %{width: width, height: height}])
    |> JS.call("draw", [data_points])
  end
end

This would not only simplify the code, but would be a lot easier for creating a said high-level APIs. We could use a special map key. Let’s call it a __hologram_unwrap__ (instead of __struct__ special key) for now. What do you think about it?

Maybe instead of taking ref from String.t() we could use atoms in low-level API. Think that instead of JS.ref("Chart") we simply use :Chart and JS.ref("document") would be changed to :document. Hologram should not care what way is used and this solution could really improve the DX.

If both ideas would be accepted this is how the updated code would look like:

defmodule Chart do
  alias Hologram.JS

  def draw(element_id, width, height, data_points) do
    # already unwrapped
    element = JS.call(:document, "getElementById", [element_id])

    if is_nil(element) do
      # handle case when element is missing
    end

    # no extra calls just to create JS reference - it would be done by Hologram itself
    :Chart
    |> JS.create([element, %{width: width, height: height}])
    |> JS.call("draw", [data_points])
  end
end

Look that we don’t really need a high-level API to make code much cleaner. It perfectly follows Elixir’s 10x less code rule, so we only focus on the logic of our application. From here the high-level API would in fact be something like a syntax sugar, see:

defmodule MyHologramLib.HighLevelJS do
  alias Hologram.JS

  def get_element_by_id(atom_or_unwrapped \\ :document, id) do
    JS.call(atom_or_unwrapped, "getElementById", [id])
  end
end

It’s so simple! Doesn’t it look just amazing? :heart_eyes:

1 Like

It looks very custom tbf. Usually when you need to reach into js you just want to do some js stuff and send some results back and forth.

If I want this level of control I would reimplement the chart in Hologram.

3 Likes

One does not need to conflict with each other. It’s obvious for everyone that for example external JavaScript libraries would be supported (most probably simply by giving an src in script tag). You can obvious put your own scripts the same way and call them with this low-level API.

1 Like

That sounds very complex compared to what a simple port or event bridge would look like.

We look for a solution in framework that’s changing Elixir code to JavaScript or “simplicity” instead? If we want simplicity then why we even bothering about adding some complex stuff like ports? Why we even care about using Elixir? We could simply call addEventListener on custom Hologram events. That would be the “simplest” solution.

We talk about DX and not simplicity. We want to have such functions because we want to use them within actions instead of writing another big blob of JavaScript code. If we are trying to avoid JavaScript and allow using only some libraries for compatibility then why we even consider writing more JavaScript in ports? For me it does not makes any sense …

The above proposals happen naturally and suddenly you came and say it’s too complex? What’s so complex here? You can write you own Chart in JavaScript and just use the above proposed functions to initialise it with maybe even one call. Yeah, very complex … :sweat_smile:

If there is something complex in context of our discussion then it’s a fact that we have to implement port or event bridges not because they give us some reasonable feature, but only because they look “simpler” for someone only because someone is used to work with Elm before or something like that. If we would be so hard into JavaScript then we would not care about writing Elixir code anyway …

While I initially proposed was a handy high-level API, after the discussion I understood that the low-level one would give us much more than what I was originally expecting. I honestly don’t see here any place for JavaScript-based solution here (in context of writing Elixir code). As said you can move 99% of your logic to JavaScript and use a single call to initialise it when needed. In Hologram ecosystem it would be rather considered a bad practice, but I don’t see why anyone would consider stopping you from doing that.

If I want to use google maps I am not looking to avoid javascript, I want to use javascript in the most practical manner possible.

Hologram is about isomorphic solution to 80-90% of your app, then for the rest where I can use pre-built stuff that is supported by a profitable business I just want a very ergonomic way to do javascript.

I do not think Bart spending time re-implementing Javascript in elixir is a good use of his time for that :person_shrugging:

5 Likes

If you talk about DX, you explicitly do not want to reimplement JavaScript in Elixir. We have millions of dollars invested into JavaScript tooling; language servers, linters, build tools, runtime debugging tools.

defmodule Chart do
  use Hologram.JS.Interop

  def draw(element_id, width, height, data_points) do
    element =
      js_ref("document")
      |> js_call("getElementById", [element_id])

    if is_nil(js_unwrap(element)) do
      # handle case when element is missing
    end

    js_ref("Chart")
    |> js_create([element, %{width: width, height: height}])
    |> js_call("draw", [data_points])
  end
end

This gets rid of all of that.

If you want to write JavaScript, just write JavaScript. Our tooling already understands it.

1 Like

Meanwhile from Hologram’s main page:

Build rich, interactive UIs entirely in Elixir using Hologram’s declarative component system. Your client-side code is intelligently transpiled to JavaScript, providing modern frontend capabilities without relying on any JavaScript frameworks.

This in context of compatibility with 3rd-party .js libraries by simply putting them in templates (script with src attribute). I really don’t see a reason for attempt on convert everyone into JavaScript religion even if the core point is clearly states that we want to work “entirely in Elixir”.

If you don’t like it go for node. I bet there is no better environment for JavaScript tooling. No, I don’t want JavaScript tooling in Elixir codebase. I’m more than fine with Elixir’s tooling. Everything that I would need would be a high level API done by me or someone else, so that the Elixir tooling could use all it’s power.

The false assumption is absolutely the worst thing people can have. Who said anything about writing JavaScript code? The interop is all about 2 things:

  1. Backwards compatibility - support initialisation calls of 3rd-party JavaScript libraries (simply because it’s not worth to rewrite every existing one) that are in separate .js files.

  2. Access the same API the JavaScript can. The point would be same no matter what language would be used in browsers. All we want is to access DOM and other APIs and not write JavaScript code. It’s like in X language you would like to add some feature from Elixir and I would say just use Elixir instead. No, I want to have access to same feature, not to write code in other language.

With this proposal you are literally writing JavaScript, just with extra steps and without any of the available tooling. Like, why would you have “draw” as a string when in JavaScript you get the function as autocomplete?

Having the goal to translate higher-level APIs to JavaScript is fine, but copying the API one-to-one doesn’t sound very appealing?

2 Likes

I agree that event-based communication is generally more testable and aligns well with JavaScript’s async nature.

However, I wouldn’t use LiveView as a reference point here - LiveView didn’t really have a choice in this regard due to its specific architecture (server-side components communicating over WebSocket). It had to go the event route.

But here’s where I see the tradeoff: events force async communication even when you just need to call a simple synchronous function. Think about cases like:

  • Querying information: chart.getWidth(), element.getBoundingClientRect()
  • Mathematical operations: Math.cos(angle) (normally you’d use the transpiled Elixir equivalent, but if there’s a bug in Hologram’s implementation and you need a working solution immediately, direct JS access becomes essential)
  • Feature detection: Modernizr.webgl
  • Validation: validator.isEmail(input) (same escape hatch scenario as with Math.cos, but let’s say there’s no suitable Elixir library that provides this specific validation behavior you need)

These naturally return immediate values. Making them async adds complexity and breaks the natural flow of your logic.

And this becomes especially important when you consider that many of these operations will need to go through interop until Hologram has official wrappers for specific Web APIs, DOM features, or browser-specific functionality. For instance, if you need getBoundingClientRect() today for a layout calculation, you can’t wait for Hologram to build a high-level DOM measurement API - you need direct access to that synchronous browser function right now.

Performance can also be a factor - for high-frequency operations (animations, real-time data processing), the event serialization overhead and callback coordination can become noticeable.

So with events, you’re essentially shifting control from Elixir to JavaScript, which can be good or bad depending on the case. I don’t think it’s that black and white.

But here’s the thing - FFI could actually enable the pattern you’re describing. If we have a low-level API for power users, we can build higher-level abstractions on top. For example:

def dispatch_event(name, params) do
  event = JS.new("Event", ["my_custom_event_name"])
  JS.call("window", "dispatchEvent", [event])
end

This way, those who want the event-based pattern can have it, but we don’t lock out use cases that need direct function calls. The FFI approach gives us both options rather than forcing one pattern for everything.

What do you think about having that flexibility?

3 Likes

Interesting discussions, I think one key aspect is to make sure you can store the JS return values and use them in later calls, or maybe you already thought about that?

Let’s say you do chart = JS.new(“ChartLibrary”, [{data: nil}] and then in somewhere later you want to access chart and do something like JS.call(chart, "populateData", [...]) on it, how would that work?

1 Like