docs-javascript-interop.md
Hologram lets you call JavaScript from your Elixir client-side code, and vice versa. You can import JS modules, call functions, manipulate objects, dispatch DOM events, and work with async code.
JS interop is useful when you need to:
Here is a quick example showing a page that imports an npm package and uses it via JS interop:
defmodule MyApp.CalculatorPage do
use Hologram.Page
use Hologram.JS
js_import from: "decimal.js", as: :Decimal
route "/calculator"
layout MyApp.DefaultLayout
def init(_params, component, _server) do
put_state(component, :result, nil)
end
def template do
~HOLO"""
<button $click={:calculate}>Calculate</button>
<p>Result: {@result}</p>
"""
end
def action(:calculate, _params, component) do
result =
:Decimal
|> JS.new([100])
|> JS.call(:plus, [23])
|> JS.call(:toNumber, []) # 123
put_state(component, :result, result)
end
end
Hologram JS interop works in both directions:
JS.call, JS.new, JS.get, JS.set, and friends give you direct, fine-grained control over JavaScript objects from Elixir. Calls work synchronously in event loop lock-step with your action handlers (unless you deliberately use async calls), integrate with state management, type conversion, and async handling, and keep your JS integration logic inside your Elixir modules.Hologram.dispatchAction() triggers Elixir action handlers from JS code.Migrating from Phoenix LiveView? The JS.dispatch_event function maps closely to the LiveView hooks pattern for a quick migration path. See the "Coming from LiveView" section below.
Add use Hologram.JS to any module that runs on the client side where you need JS interop:
defmodule MyApp.DashboardPage do
use Hologram.Page
use Hologram.JS
# ...
end
This imports the js_import macro, the ~JS sigil, and aliases the Hologram.JS module as JS.
Use js_import at the top of your module to make JavaScript exports available as named bindings. Bindings are scoped to the Elixir module where they are declared, and can be used as receivers or function references in JS.* calls within that module.
Each js_import maps directly to a JavaScript import statement:
| Elixir | Resolves To |
|---|---|
| js_import from: "decimal.js", as: :Decimal | import Decimal from "decimal.js" |
| js_import :multiply, from: "./helpers.mjs" | import { multiply } from "./helpers.mjs" |
| js_import :Chart, from: "chart.js", as: :MyChart | import { Chart as MyChart } from "chart.js" |
Import the default export of a JS module using the :from and :as options. Given this JavaScript file:
// calculator.mjs
export default class Calculator {
constructor(initial) {
this.value = initial;
}
add(n) {
this.value += n;
return this.value;
}
}
You can import it in Elixir like this:
js_import from: "./calculator.mjs", as: :Calculator
Both :from and :as are required for default imports.
Import a specific named export. Given this JavaScript file:
// helpers.mjs
export function multiply(a, b) {
return a * b;
}
You can import it in Elixir like this:
js_import :multiply, from: "./helpers.mjs"
The binding name defaults to the export name. Use :as to alias it:
js_import :Chart, from: "chart.js", as: :MyChart
Import paths are resolved as follows:
./ or ../) are resolved relative to the Elixir source file that contains the js_import. This means you can colocate JS files next to your Elixir modules:lib/my_app/pages/
dashboard_page.ex # js_import from: "./chart_helpers.mjs", as: :ChartHelpers
chart_helpers.mjs
./ or ../ prefix) are resolved as npm packages:js_import from: "decimal.js", as: :Decimal
Make sure the package is installed in your assets/package.json.
# From lib/my_app/pages/my_page.ex, reaching into assets/js/:
js_import :formatDate, from: "../../../assets/js/utils.mjs"
Each binding name must be unique within a module. Declaring two imports with the same :as name will raise a compile-time error.
Calls a function without a receiver. The function is resolved from your js_import bindings first, then from window.
js_import :multiply, from: "./helpers.mjs"
# Call an imported function
JS.call(:multiply, [4, 6]) # 24
# Call a global function
JS.call(:parseInt, ["42abc"]) # 42
Calls a method on a receiver. The receiver can be:
:Math, :document)JS.new/2)js_import from: "./helpers.mjs", as: :helpers
# Call a method on an imported module
JS.call(:helpers, :sum, [1, 2]) # 3
# Call a method on a global object
JS.call(:Math, :round, [3.7]) # 4
# Call a method on a native object reference
:Calculator
|> JS.new([10])
|> JS.call(:add, [5]) # 15
Elixir anonymous functions can be passed as callbacks to JavaScript functions. They are automatically converted to JavaScript functions and their return values are converted back. Given this JavaScript file:
// helpers.mjs
const helpers = {
mapArray(arr, fn) {
return arr.map(fn);
},
};
export default helpers;
You can pass an Elixir anonymous function as a callback:
callback = fn x -> x * 2 end
JS.call(:helpers, :mapArray, [[1, 2, 3], callback]) # [2, 4, 6]
Creates a new instance of a JavaScript class:
js_import from: "./calculator.mjs", as: :Calculator
:Calculator
|> JS.new([10])
|> JS.call(:add, [5]) # 15
The class can be an imported binding or a global constructor.
Reads a property from a JavaScript object:
value =
:Calculator
|> JS.new([10])
|> JS.get(:value) # 10
Sets a property on a JavaScript object. Returns the receiver (for chaining):
value =
:Calculator
|> JS.new([10])
|> JS.set(:value, 20)
|> JS.get(:value) # 20
Deletes a property from a JavaScript object. Returns the receiver (for chaining):
result =
:Calculator
|> JS.new([42])
|> JS.delete(:value)
|> JS.get(:value)
|> JS.typeof() # "undefined"
Returns the JavaScript typeof result as a string:
:Calculator
|> JS.new([10])
|> JS.typeof() # "object"
Checks if a value is an instance of a JavaScript class. Returns a boolean:
:Calculator
|> JS.new([10])
|> JS.instanceof(:Calculator) # true
Evaluates a JavaScript expression and returns the result:
JS.eval("3 + 4") # 7
The expression is evaluated as a single expression (not statements). For multi-line code, use JS.exec/1.
Executes arbitrary JavaScript code. You can optionally use return to produce a value:
JS.exec("""
const x = 2;
return x + 3;
""") # 5
The ~JS sigil is a shorthand for JS.exec/1. It is convenient for DOM manipulation and fire-and-forget code:
~JS"""
const el = document.getElementById('my-element');
el.textContent = 'Updated!';
"""
The ~JS sigil can also return a value:
~JS"""
const x = 7;
return x + 4;
""" # 11
Dispatches an event on a target. There are several variants:
target = JS.call(:document, :getElementById, ["my-element"])
JS.dispatch_event(target, "my:event")
JS.dispatch_event(target, "my:event", detail: %{value: 99})
JS.dispatch_event(target, :MouseEvent, "click")
JS.dispatch_event(target, :CustomEvent, "my:event", cancelable: true)
Use atoms like :document or :window as the target:
JS.dispatch_event(:document, "my:event")
You can combine dispatch_event with callback interop to set up listeners:
target = JS.call(:document, :getElementById, ["my-element"])
JS.call(target, :addEventListener, [
"my:event",
fn event ->
detail = JS.get(event, :detail)
JS.set(:window, : __captured__ , JS.get(detail, :value))
end
])
JS.dispatch_event(target, "my:event", detail: %{value: 42})
JS.get(:window, : __captured__ ) # 42
In Hologram's client-side Elixir runtime, JavaScript Promises become Elixir Tasks. Use Task.await/1 to get the result, just like you would with any other Task in Elixir.
The examples in this section use the following JavaScript file:
// helpers.mjs
const helpers = {
async asyncSum(a, b) {
return a + b;
},
promiseSum(a, b) {
return new Promise((resolve) => {
setTimeout(() => resolve(a + b), 100);
});
},
};
export class AsyncCounter {
constructor(initial) {
return new Promise((resolve) => {
setTimeout(() => resolve({ value: initial + 1 }), 50);
});
}
}
export const promiseValue = {
data: new Promise((resolve) => {
setTimeout(() => resolve(77), 50);
}),
};
export default helpers;
If a JavaScript function is async or returns a Promise, JS.call/3 returns a Task:
# Async function
result =
:helpers
|> JS.call(:asyncSum, [10, 20])
|> Task.await() # 30
# Promise-returning function
result =
:helpers
|> JS.call(:promiseSum, [100, 200])
|> Task.await() # 300
If the evaluated expression or executed code returns a Promise, JS.eval/1 and JS.exec/1 return a Task:
result =
"fetch('/api/data').then(r => r.json())"
|> JS.eval()
|> Task.await()
If a property value is a Promise, JS.get/2 returns a Task:
result =
:promiseValue
|> JS.get(:data)
|> Task.await() # 77
If a constructor returns a Promise (e.g. for async initialization), JS.new/2 returns a Task:
obj =
:AsyncCounter
|> JS.new([50])
|> Task.await()
result = JS.get(obj, :value) # 51
You can dispatch Hologram actions directly from JavaScript code using Hologram.dispatchAction(). This is useful for integrating third-party JS libraries or handling events outside of Hologram's template system.
Hologram.dispatchAction("my_action", "page", { amount: 42, label: "test" });
Parameters:
| Parameter | Type | Description |
|---|---|---|
actionName | string | The action name (e.g. "my_action" maps to def action(:my_action, ...)) |
target | string | "page" for page actions, "layout" for layout actions, or a component CID for component actions |
params | object | (Optional) A plain JS object. Keys become atom keys in the params map |
Corresponding action in your page, layout, or component:
def action(:my_action, params, component) do
# params.amount => 42 (integer)
# params.label => "test" (string)
put_state(component, :result, {params.amount, params.label})
end
Hologram.dispatchAction() is available immediately - even before the Hologram runtime has fully loaded. Actions dispatched before initialization are queued and replayed once the page mounts. This is useful for inline <script> tags in templates:
~HOLO"""
<script>
Hologram.dispatchAction("init_from_js", "page", {value: 99});
</script>
"""
Note: Escape curly braces with backslashes (\{, \}) inside ~HOLO templates to prevent them from being interpreted as Hologram expressions. Alternatively, wrap the script body in a {%raw}...{/raw} block to disable template syntax entirely:
~HOLO"""
<script>
{%raw}
Hologram.dispatchAction("init_from_js", "page", {value: 99});
{/raw}
</script>
"""
Values are automatically converted when crossing the Elixir/JavaScript boundary.
| Elixir Type | JavaScript Type |
|---|---|
| integer | number / bigint* |
| float | number |
| boolean | boolean |
| nil | null |
| string | string |
| list | Array |
| tuple | Array |
| map | Object |
| anonymous function | Function |
| atom | JS binding** / string |
* Integers outside the safe integer range (Number.MIN_SAFE_INTEGER to Number.MAX_SAFE_INTEGER) are passed as bigint.
** Atoms are resolved in order: js_import binding, then window property, then string fallback. Exception: atom keys in maps are always converted to strings.
| JavaScript Type | Elixir Type |
|---|---|
| number | integer / float* |
| boolean | boolean |
| null | nil |
| undefined | opaque native value |
| string | string (bitstring) |
| Array | list |
| Plain Object | map (string keys) |
| Promise | Task (await with Task.await/1) |
| Class instance / other object | opaque native value |
| bigint | opaque native value |
| Function | opaque native value |
| Symbol | opaque native value |
* Determined by Number.isInteger(): values like 42 or 3.0 become integer, values like 3.14 become float.
Opaque native values are JavaScript values that don't have a direct Elixir equivalent. They are represented as Hologram.JS.NativeValue structs and can be passed back to JS interop functions. You can pattern match on the type field (:bigint, :function, :object, :symbol, :undefined).
Hologram's virtual DOM patching is aware of JS interop. If you manipulate the DOM directly via JS interop (e.g. appending child elements), those changes are preserved across Hologram re-renders, as long as the container element is present in the template:
def template do
~HOLO"""
<button $click={:populate}>Populate JS subtree</button>
<button $click={:increment}>Increment counter</button>
<p>Counter: {@counter}</p>
<div id="js_managed"></div>
"""
end
def action(:populate, _params, component) do
~JS"""
const container = document.getElementById('js_managed');
const span = document.createElement('span');
span.textContent = 'JS managed content';
container.appendChild(span);
"""
put_state(component, :counter, component.state.counter + 1)
end
def action(:increment, _params, component) do
put_state(component, :counter, component.state.counter + 1)
end
In this example, clicking "Populate JS subtree" adds a span via JavaScript and increments the counter. Clicking "Increment counter" afterwards will update the counter display without removing the JS-managed span.
If you're migrating from Phoenix LiveView, Hologram offers two paths for JS interop. The event-based dispatching API (JS.dispatch_event and Hologram.dispatchAction()) maps directly to LiveView hooks patterns and is provided as a convenience for faster migration - it lets you port existing hook-based integrations with minimal changes.
However, for new code the low-level API is the recommended approach. Functions like JS.call, JS.new, JS.get, and JS.set are tightly integrated with the Hologram runtime - they work synchronously in event loop lock-step with your action handlers, interact naturally with component state management, support automatic type conversion, and handle async results through Task.await/1. This keeps your JS integration logic inside your Elixir modules rather than split across separate hook files, and avoids the indirection of passing data through events. The low-level API has no LiveView equivalent - it's a new capability unique to Hologram.
In LiveView, you use this.pushEvent() inside a hook to send a message to the server:
// LiveView hook
Hooks.MyChart = {
mounted() {
this.pushEvent("chart_clicked", { point: 42 });
}
};
# LiveView handler (server-side)
def handle_event("chart_clicked", %{"point" => point}, socket) do
{:noreply, assign(socket, :selected, point)}
end
In Hologram, use Hologram.dispatchAction() - no hook boilerplate needed:
// Anywhere in your JavaScript
Hologram.dispatchAction("chart_clicked", "page", { point: 42 });
# Hologram handler (runs in the browser)
def action(:chart_clicked, params, component) do
put_state(component, :selected, params.point)
end
In LiveView, you push events from the server to a hook's handleEvent callback:
# LiveView (server-side)
def handle_event("refresh", _params, socket) do
{:noreply, push_event(socket, "data_updated", %{value: 99})}
end
// LiveView hook
Hooks.MyChart = {
mounted() {
this.handleEvent("data_updated", ({ value }) => {
this.chart.update(value);
});
}
};
In Hologram, dispatch an event and listen for it with addEventListener:
# Hologram action (runs in the browser)
def action(:refresh, _params, component) do
target = JS.call(:document, :getElementById, ["my-chart"])
JS.dispatch_event(target, "data_updated", detail: %{value: 99})
component
end
// Standard listener (no hook needed)
document.getElementById("my-chart").addEventListener("data_updated", (event) => {
chart.update(event.detail.value);
});
Alternatively, you can set up the listener entirely from Elixir using the low-level API:
def action(:setup_chart, _params, component) do
target = JS.call(:document, :getElementById, ["my-chart"])
JS.call(target, :addEventListener, [
"data_updated",
fn event ->
detail = JS.get(event, :detail)
JS.call(:chart, :update, [JS.get(detail, :value)])
end
])
component
end
Hooks object, wiring it to elements with phx-hook, and implementing lifecycle callbacks (mounted, updated, destroyed). Hologram uses standard DOM APIs directly.pushEvent and push_event communicate over WebSocket between browser and server. Hologram's interop runs entirely in the browser, so there's no latency.JS interop functions can only be used in functions reachable through action handlers. Since actions are not executed during SSR (only init runs server-side), JS interop code never runs on the server.
On the server side, JS.* calls are no-ops - most return :ok, while JS.set/3 and JS.delete/2 return the receiver to preserve chaining. js_import is compile-time only and has no runtime effect.
Since JS interop functions only work in the browser, functions that use them cannot be covered by Elixir unit tests. Use feature tests (browser-based integration tests) to verify JS interop behaviour.
JS.call directly in your pages and components. This keeps your pages and components focused on UI logic and makes the JS dependency easy to swap..mjs file should have a single responsibility rather than becoming a utility grab-bag.JS.call over JS.exec / JS.eval - Structured calls to imported modules are easier to maintain and debug than inline string-based code.Planning to publish a reusable package that wraps a JavaScript library, a Web API, or provides Hologram utilities? Check the Package Naming conventions to help distinguish official and community packages.
Sponsored by
Previous
Next