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

It’s time to implement JavaScript interop for Hologram!

Eventually, there will be Hologram wrappers for most APIs, but sometimes we’ll need escape hatches - especially when working with legacy apps or integrating 3rd party libraries. Things like:

  • Integrating charting libraries (Chart.js, D3, etc.)
  • Using JavaScript-only APIs (Web APIs, browser-specific features)
  • Calling into existing JavaScript codebases
  • Leveraging the vast npm ecosystem

How would you envision a Hologram <> JavaScript interop API/DSL?

Some questions to spark discussion:

  • Should it be declarative or imperative?
  • How should data be marshaled between Elixir and JavaScript?
  • What would the ideal developer experience look like?
  • Are there specific JavaScript libraries you’re eager to use?
  • How should errors be handled across the boundary?

I have some ideas brewing, but I don’t want to bias the discussion by sharing them upfront. I’m genuinely curious about your use cases and what would make the most sense from a developer ergonomics perspective.

Looking forward to hearing your thoughts!

12 Likes

For libraries I would prefer to call JS code without any changes. This way would make integrations simple and not depend on the Hologram or any other package. The most what we can do here is to query element using Hologram and pass it to JS code (something like an assign), so an Elixir term would be translated to JS DOM element.

For everything else I would like to have a Hologram wrappers. This way we should be able to even re-write JS library in Elixir with a proper DOM and WebAPI bindings. Depending on case some of those are short term (like simply read/write some client state) or long term (rewritten/new libraries that would be compiled to JS).

It would be interesting to write a library in Elixir and export it in a legacy or modern JS. One configuration thing would handle a lot of use cases in my opinion.

1 Like

Thanks for the input @Eiji! Could you share some rough code snippets showing how you’d envision the API/DSL? Even pseudocode would help.

Not sure how Hologram translates things, but in very short something like:

window = Hologram.DOM.get_window()
document = Hologram.DOM.get_document(window)
# 1:1 map of DOM APIs with Elixir naming
element = Hologram.DOM.query_selector(document, "#element-id")
map = Hologram.DOM.dataset(element)
# or 
value = Hologram.DOM.dataset(element, "key")

Hologram.DOM.add_event_listener(element, "click", fn event ->
  # …
end)
# or
Hologram.DOM.add_event_listener(element, "click", {:action, :action_name})
# or
Hologram.DOM.add_event_listener(element, "click", {:command :command_name})
# best if all of above would be supported

For JS we could have something like:

# For a nice error messages like:
# Failed to init lib_name, error: "Syntax error at …"
Hologram.JS.init_lib_with(:lib_name, ~JS"""
  JSLib.init({…})
""")

The point of above snippet is that we could call it in every init or action. This way we could for example initialise an editor in a tabbed only when switching to the right tab for example.

2 Likes

I was thinking it might be as simple as:

def template do
  ~JS"""
    # import js libs here
    # write your js code here
  """
end 

I’m not sure how calling Hologram actions and commands from the js code should work. I’m assuming that would be needed. Maybe some sort of shorthand that can be parsed when scanning the JS block?

Edit: I think we’d also need a way to execute JS outside of a template context. One example would be using the npm package idb which makes indexeddb easier to work with.

Maybe it could be as simple as dropping a .js file somewhere that Hologram would pick up and bundle and allow importing of its functions into Hologram. Maybe something like:

defmodule MyComponent do
  use Hologram.Component

  alias Hologram.JS.IDB # there is a corresponding IDB.js which houses the IDB logic written entirely in js

  def action(…) do
    …
    IDB.something(…)
  end
end

These thoughts are a bit off the cuff so take them for what you will :slightly_smiling_face:

1 Like

Thanks Eiji. Can you also give some use case for the code?

For example, assuming you could do this:
element = Hologram.DOM.query_selector(document, "#element-id")
what would you do with the element?

And in what cases you would need to use this:
Hologram.DOM.add_event_listener(element, "click", {:action, :action_name})
outside of .holo templates?

As in example code I was fetching the data-* attributes using dataset API and adding an event listeners for it.
https://caniuse.com/dataset

click event is rarely used also in LiveView, but it’s just a generic example that everyone would understand. There could be a custom events (especially when integrating with 3rd party JS libraries). Sometimes you want to temporarily add some event and the remove it. In some cases you want to stop event propagation based on some conditions.

There are tons of examples for all those cases. One of them is a “click away” implementation in vanilla JS that is possible by using a event.target element and contains API.

Such event listener may be called once and removed to not cause any conflicts with the rest of code.

See also a guide for creating and dispatching custom events:

1 Like

I think I understand the technical approach you’re proposing, but I’m trying to dig deeper into the specific use cases to make sure we’re solving real problems.

Regarding your example with <div id="element-id" data-user="123" data-role="admin"></div> - how would this work in practice since that div would already be managed by Hologram’s template system? Are you thinking about scenarios where you’re rendering some portions of the page outside of Hologram’s templates and then managing them through Hologram? Or are you envisioning querying elements that are already part of your .holo templates?

I’m trying to think through specific use cases and focus on the most common ones that are actually worth implementing. I don’t want to build features just for the sake of it.

The custom event listeners from 3rd party libraries - that makes total sense to me as a clear interop need. But for things like “click away” patterns or event propagation control, my thinking is that the whole goal of JS interop shouldn’t be to create a separate way of doing things in Hologram. The primary reason should be interoping with 3rd party libs and escape hatches.

For the rest, I’d rather cover it within Hologram’s way of thinking. For example - there’s no $click_away event, but there definitely will be. Same with event bubbling - Hologram should probably allow you to manage that within its template system rather than requiring you to drop down to imperative DOM manipulation.

Does that make sense? I’m trying to draw a line between “this needs JS interop because it’s inherently about external libraries and/or escape hatches” vs “this should be a first-class Hologram feature.”

What do you think?

For me the usecase is very straight forward and I suggest you even use it both as a thing to prototype with and as a showcase of interop.

I want to do a <div id=”map”/> and then initialize google maps on it.

In regular liveview this would just mean setting phx-update=”ignore” on it and start mapping JS hook events.

In Hologram I have no idea what would be most idiomatic since I haven’t really dug in yet due to lack of said JS interop.

I propose three usecases:

  1. Get the corners of the map (essentially the lon/lat of top left + lower right)
  2. Add a marker (ideally with some props like marker color etc)
  3. Delete a marker (this means you need to thread through back to Hologram the ID of an added marker)
3 Likes

Well … since you are changing Elixir code to JavaScript people would expect all JS bindings or they would write their own scripts. It’s really hard to tell what API would be needed as each app requires completely different things.

For example you may think that since Hologram handles all communication the XMLHTTPRequest is not needed any more. That’s true for requests send to same server, but what about 3rd party APIs? In many cases you want to move as much as possible to client, so the server focus on it’s core stuff.

data-* attributes may not be a best example, but what about classlist methods? Do you prefer to just add/remove/toggle class or maintain a list of classes? Use cases? It’s rather completely or almost completely up to developer creativity and specific project needs.

There are way to many cases to just mention them not even saying about describing or discussing each one.

However sure, you may be interested to focus on things that Hologram templates does not cover (better or worse). Anyway you would have lots of work with bindings for Canvas API, push notifications, sounds and many, many more … Today in browser you can do literally everything including FPS games. Of course nobody expects APIs related to FPS games in that early stage, but you would have to support everything sooner or later.

At the very end you can do a best practices to avoid working on attributes if the same could be done in templates. However I would not be surprised if one day someone would come with a huge template example and start to complain that all the logic has to be done in it as there are no JS bindings available that would split templates and business logic. Again everything depends on case. There is no rule to cover all cases, so better to support as much as possible.

If one day you would do so then people instead of complaining would rewrite the JavaScript libraries into Hologram components as with complete API, the migration would be very simple, easily work in legacy browsers and everything could be done with same code style (see the Elixir naming in Hologram.DOM mentioned previously).

1 Like

Hi, @bartblast

Have you considered ideas from Elm?

1 Like

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?

3 Likes

Make a poll? :slight_smile:

I vote 1, feels most idiomatic and a pattern we alrrady know

1 Like

At the moment, I’d vote 2. Seems simplest to me and feels like the most general / flexible which I think would be important. Though I would like to see some examples of 1 in the Hologram context.

3 Likes

Oh, that’s simple then. First we need a lot of structs (for window, document, element, event and all other data used in APIs). Then we need a helper functions (getters, setters for properties and apply) or sigil for a JS template, for example:

def get_element_by_id(component_or_page, id) do
  component_or_page
  |> Hologram.JS.get_document()
  |> Hologram.JS.apply(:getElementById, [id])
end

def set_data(element, key, value) do
  element
  |> Hologram.JS.get_property(:dataset)
  |> Hologram.JS.set_property(key, value)
end

# or with templates:
def get_element_by_id(component_or_page, id) do
  assigns = %{document: Hologram.JS.get_document(component_or_page), id: id}

  ~HJS"""
  return {@document}.getElementById({@id});
  """
end

def set_data(element, key, value) do
  assigns = %{element: element, key: key, value: value}

  ~HJS"""
  return {@element}.dataset.{@key} = {@value};
  """
end

The first examples should be simpler than your 3rd option as we just need a few simple functions and some helpful structs that would be properly translated and received. The alternative with templates is similar to your 2nd FFI option. I don’t like the first option for message passing as it’s rather against Elixir’s 10x less code rule.

Personally I would prefer to not use templates for JS, but some bindings like in my first examples. They should be relatively simple to implement. I’m not sure how things work internally, but most probably you can do this just by enhancing your client-side Elixir runtime. You already translate data in templates, so you can do same with actions.

I think that JavaScript equivalent for atoms are symbols, see:

Symbol is a built-in object whose constructor returns a symbol primitive — also called a Symbol value or just a Symbol — that’s guaranteed to be unique. Symbols are often used to add unique property keys to an object that won’t collide with keys any other code might add to the object, and which are hidden from any mechanisms other code will typically use to access the object.

Source: Symbol - JavaScript | MDN

I believe that you can create tuple-like arrays in JavaScript by using EcmaScript 2015 feature called Object.seal, see:

I guess there are lots of ways for pattern-matching (if needed at all), see:

I don’t think that immutable data structures needs to be translated.

2 Likes

I like option 2. It’s naturally analogous to NIFs, so it feels like Elixir’s existing escape hatch into other languages. The async requirement of option 1 I think would be too complex for many use cases. Option 3 sounds like a nightmare to implement.

3 Likes

A follow-up thought: If these calls were automatically wrapped with try/catch, that could eliminate the problem of runtime errors, right? I’m sure that is some overhead, but I don’t know just how much. Perhaps try/catch would be the default wrapper, with an option to disable it for performance-critical calls. :thinking:

1 Like

Using try/catch for entre JavaScript API is rather an anti-pattern. Generally Elixir ecosystem prefers let it fail as fail and restart process is much simpler and therefore faster than catching all possible errors and passing all error data. I guess it would be better to let people spawn tiny processes.

1 Like

But why would you want to «call» functions like that.

We are talking about interfacing with libs that you generally implement promises or callbacks for. The eventing system that hooks in lv already have to interface with this works very well.

With option 2 you need to hold all the client only state for a given third party flavour in hologram/elixir. That sounds like both a lot of work and a lot of friction. I just want to signal that hologram says to add three new markers or for the js side to send me an event saying that rhe viewport updated.

The foreign function way doesnt even solve that last thing.

Bidirectional message ish passing with events is both fully typeable and very simple to debug and test. And the hologram side needs to be a lot less degensively coded.

3 Likes

I don’t mean for the entire API, just at the boundary of the FFI interface to ensure an exception doesn’t crash the entire application.

Of course, if Hologram is already doing some sort of supervision tree emulation, that isn’t necessary and we can fall back on the let it fail ideology. I just assumed that wasn’t the case since we’re talking about calling into arbitrary JS here.

1 Like