guides/client/js-interop.md
To enable LiveView client/server interaction, we instantiate a LiveSocket. For example:
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
liveSocket.connect()
All options are passed directly to the Phoenix.Socket constructor,
except for the following LiveView specific options:
bindingPrefix - the prefix to use for phoenix bindings. Defaults "phx-"params - the connect_params to pass to the view's mount callback. May be
a literal object or closure returning an object. When a closure is provided,
the function receives the view's element.hooks - a reference to a user-defined hooks namespace, containing client
callbacks for server/client interop. See the Client hooks
section below for details.uploaders - a reference to a user-defined uploaders namespace, containing
client callbacks for client-side direct-to-cloud uploads. See the
External uploads guide for details.metadata - additional user-defined metadata that is sent along events to the server.
See the Key events section in the bindings guide
for an example.The liveSocket instance exposes the following methods:
connect() - call this once after creation to connect to the serverenableDebug() - turns on debug logging, see Debugging client eventsdisableDebug() - turns off debug loggingenableLatencySim(milliseconds) - turns on latency simulation, see Simulating latencydisableLatencySim() - turns off latency simulationexecJS(el, encodedJS) - executes encoded JavaScript in the context of the elementjs() - returns an object with methods to manipulate the DOM and execute JavaScript. The applied changes integrate with server DOM patching. See JS commands.To aid debugging on the client when troubleshooting issues, the enableDebug()
and disableDebug() functions are exposed on the LiveSocket JavaScript instance.
Calling enableDebug() turns on debug logging which includes LiveView life-cycle and
payload events as they come and go from client to server. In practice, you can expose
your instance on window for quick access in the browser's web console, for example:
// app.js
let liveSocket = new LiveSocket(...)
liveSocket.connect()
window.liveSocket = liveSocket
// in the browser's web console
>> liveSocket.enableDebug()
The debug state uses the browser's built-in sessionStorage, so it will remain in effect
for as long as your browser session lasts.
Proper handling of latency is critical for good UX. LiveView's CSS loading states allow
the client to provide user feedback while awaiting a server response. In development,
near zero latency on localhost does not allow latency to be easily represented or tested,
so LiveView includes a latency simulator with the JavaScript client to ensure your
application provides a pleasant experience. Like the enableDebug() function above,
the LiveSocket instance includes enableLatencySim(milliseconds) and disableLatencySim()
functions which apply throughout the current browser session. The enableLatencySim function
accepts an integer in milliseconds for the one-way latency to and from the server. For example:
// app.js
let liveSocket = new LiveSocket(...)
liveSocket.connect()
window.liveSocket = liveSocket
// in the browser's web console
>> liveSocket.enableLatencySim(1000)
[Log] latency simulator enabled for the duration of this browser session.
Call disableLatencySim() to disable
When the server uses Phoenix.LiveView.push_event/3, the event name
will be dispatched in the browser with the phx: prefix. For example,
imagine the following template where you want to highlight an existing
element from the server to draw the user's attention:
<div id={"item-#{item.id}"} class="item">
{item.title}
</div>
Next, the server can issue a highlight using the standard push_event:
def handle_info({:item_updated, item}, socket) do
{:noreply, push_event(socket, "highlight", %{id: "item-#{item.id}"})}
end
Finally, a window event listener can listen for the event and conditionally execute the highlight command if the element matches:
let liveSocket = new LiveSocket(...)
window.addEventListener("phx:highlight", (e) => {
let el = document.getElementById(e.detail.id)
if(el) {
// logic for highlighting
}
})
If you desire, you can also integrate this functionality with Phoenix' JS commands, executing JS commands for the given element whenever highlight is triggered. First, update the element to embed the JS command into a data attribute:
<div id={"item-#{item.id}"} class="item" data-highlight={JS.transition("highlight")}>
{item.title}
</div>
Now, in the event listener, use LiveSocket.execJS to trigger all JS
commands in the new attribute:
let liveSocket = new LiveSocket(...)
window.addEventListener("phx:highlight", (e) => {
document.querySelectorAll(`[data-highlight]`).forEach(el => {
if(el.id == e.detail.id){
liveSocket.execJS(el, el.getAttribute("data-highlight"))
}
})
})
phx-hookTo handle custom client-side JavaScript when an element is added, updated,
or removed by the server, a hook object may be provided via phx-hook.
phx-hook must point to an object with the following life-cycle callbacks:
mounted - the element has been added to the DOM and its server
LiveView has finished mountingbeforeUpdate - the element is about to be updated in the DOM.
Note: any call here must be synchronous as the operation cannot
be deferred or cancelled.updated - the element has been updated in the DOM by the server.
Note: window.location may not reflect the current URL during this callback.
For navigation-aware logic, use the phx:navigate event instead.destroyed - the element has been removed from the page, either
by a parent update, or by the parent being removed entirelydisconnected - the element's parent LiveView has disconnected from the serverreconnected - the element's parent LiveView has reconnected to the serverNote: hooks also run on regular pages that are not LiveViews — see
Hooks and JS commands outside of a LiveView.
In that case, mounted is the only callback invoked, and only those elements on the page
at DOM ready will be tracked. For dynamic tracking of the DOM as elements are added,
removed, and updated, a LiveView should be used.
The above life-cycle callbacks have in-scope access to the following attributes:
el - attribute referencing the bound DOM nodeliveSocket - the reference to the underlying LiveSocket instancepushEvent(event, payload, (reply, ref) => ...) - method to push an event from the client to the LiveView server.
Omitting the callback returns a promise that resolves to the reply.
Note: the callback version silently ignores errors.pushEventTo(selectorOrTarget, event, payload, (reply, ref) => ...) - method to push targeted events from the client
to LiveViews and LiveComponents. The selectorOrTarget can be a DOM element (such as this.el) or a query
selector string. The event is sent to the LiveComponent or LiveView owning the targeted element(s). If a selector
matches multiple elements, the event is sent to all of them, even if they belong to the same LiveComponent or LiveView.
Omitting the callback returns a promise matching
Promise.allSettled(),
where fulfilled values are of the format { reply, ref }.
Note: the callback version silently ignores errors.handleEvent(event, (payload) => ...) - method to handle an event pushed from the server. Returns a value that can be passed to removeHandleEvent to remove the event handler.removeHandleEvent(ref) - method to remove an event handler added via handleEventupload(name, files) - method to inject a list of file-like objects into an uploader.uploadTo(selectorOrTarget, name, files) - method to inject a list of file-like objects into an uploader.
The hook will send the files to the uploader with name defined by allow_upload/3
on the server-side. Dispatching new uploads triggers an input change event which will be sent to the
LiveComponent or LiveView the selectorOrTarget is defined in, where its value can be either a query selector or an
actual DOM element. If the query selector returns more than one live file input, an error will be logged.js() - returns an object with methods to manipulate the DOM and execute JavaScript. The applied changes integrate with server DOM patching. See JS commands.For example, the markup for a controlled input for phone-number formatting could be written like this:
<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook="PhoneNumber" />
Then a hook callback object could be defined and passed to the socket:
/**
* @type {import("phoenix_live_view").HooksOptions}
*/
let Hooks = {}
Hooks.PhoneNumber = {
mounted() {
this.el.addEventListener("input", e => {
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
if(match) {
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
}
})
}
}
let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, ...})
...
Note: when using phx-hook, a unique DOM ID must always be set.
For integration with client-side libraries which require a broader access to full
DOM management, the LiveSocket constructor accepts a dom option with an
onBeforeElUpdated callback. The fromEl and toEl DOM nodes are passed to the
function just before the DOM patch operations occurs in LiveView. This allows external
libraries to (re)initialize DOM elements or copy attributes as necessary as LiveView
performs its own patch operations. The update operation cannot be cancelled or deferred,
and the return value is ignored.
For example, the following option could be used to guarantee that some attributes set on the client-side are kept intact:
...
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: Hooks,
dom: {
onBeforeElUpdated(from, to) {
for (const attr of from.attributes) {
if (attr.name.startsWith("data-js-")) {
to.setAttribute(attr.name, attr.value);
}
}
}
}
})
In the example above, all attributes starting with data-js- won't be replaced when the DOM is patched by LiveView.
A hook can also be defined as a subclass of ViewHook:
import { ViewHook } from "phoenix_live_view"
class MyHook extends ViewHook {
mounted() {
...
}
}
let liveSocket = new LiveSocket(..., {
hooks: {
MyHook
}
})
When writing components that require some more control over the DOM, it often feels inconvenient to
have to write a hook in a separate file. Instead, one wants to have the hook logic right next to the component
code. For such cases, HEEx supports Phoenix.LiveView.ColocatedHook and Phoenix.LiveView.ColocatedJS.
Let's see an example:
def phone_number_input(assigns) do
~H"""
<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
export default {
mounted() {
this.el.addEventListener("input", e => {
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
if(match) {
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
}
})
}
}
</script>
"""
end
When LiveView finds a <script> element with :type={ColocatedHook}, it will extract the
hook code at compile time and write it into a special folder inside the _build/ directory.
To use the hooks, all that needs to be done is to import the manifest into your JS bundle,
which is automatically done in the app.js file generated by mix phx.new for new Phoenix 1.8 apps:
...
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
+ import {hooks as colocatedHooks} from "phoenix-colocated/my_app"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
+ hooks: {...colocatedHooks}
})
The "phoenix-colocated" package is a folder inside the Mix.Project.build_path(),
which is included by default in the esbuild configuration of new
Phoenix projects (requires {:esbuild, "~> 0.10"} or later):
config :esbuild,
...
my_app: [
args:
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
cd: Path.expand("../assets", __DIR__),
env: %{
"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]
}
]
When rendering a component that includes a colocated hook, the <script> tag is omitted
from the rendered output. Furthermore, to prevent conflicts with other components, colocated hooks
require you to use the special dot syntax when naming the hook, as well as in the phx-hook attribute.
LiveView will prefix the hook name by the current module name at compile time. This also means
that in cases where a hook is meant to be used in multiple components across a project, the hook
should be defined as a regular, non-colocated hook instead.
You can read more about colocated hooks in the module documentation for ColocatedHook.
LiveView also supports colocating other JavaScript code, for more information, see Phoenix.LiveView.ColocatedJS.
A hook can push events to the LiveView by using the pushEvent function and receive a
reply from the server via a {:reply, map, socket} return value. The reply payload will be
passed to the optional pushEvent response callback.
Communication with the hook from the server can be done by reading data attributes on the
hook element or by using Phoenix.LiveView.push_event/3 on the server and handleEvent on the client.
An example of responding with :reply might look like this.
<div phx-hook="ClickMeHook" id="click-me">
Click me for a message!
</div>
Hooks.ClickMeHook = {
mounted() {
this.el.addEventListener("click", () => {
// Push event to LiveView with callback for reply
this.pushEvent("get_message", {}, (reply) => {
console.debug(reply.message);
});
});
}
}
Then in your callback you respond with {:reply, map, socket}
def handle_event("get_message", _params, socket) do
# Use :reply to respond to the pushEvent
{:reply, %{message: "Hello from LiveView!"}, socket}
end
Another example, to implement infinite scrolling, one can pass the current page using data attributes:
<div id="infinite-scroll" phx-hook="InfiniteScroll" data-page={@page}>
And then in the client:
/**
* @type {import("phoenix_live_view").Hook}
*/
Hooks.InfiniteScroll = {
page() { return this.el.dataset.page },
mounted(){
this.pending = this.page()
window.addEventListener("scroll", e => {
if(this.pending == this.page() && scrollAt() > 90){
this.pending = this.page() + 1
this.pushEvent("load-more", {})
}
})
},
updated(){ this.pending = this.page() }
}
However, the data attribute approach is not a good approach if you need to frequently push data to the client. To push out-of-band events to the client, for example to render charting points, one could do:
<div id="chart" phx-hook="Chart">
And then on the client:
/**
* @type {import("phoenix_live_view").Hook}
*/
Hooks.Chart = {
mounted(){
this.handleEvent("points", ({points}) => MyChartLib.addPoints(points))
}
}
And then you can push events as:
{:noreply, push_event(socket, "points", %{points: new_points})}
Events pushed from the server via push_event are global and will be dispatched
to all active hooks on the client who are handling that event. If you need to scope events
(for example when pushing from a live component that has siblings on the current live view),
then this must be done by namespacing them:
def update(%{id: id, points: points} = assigns, socket) do
socket =
socket
|> assign(assigns)
|> push_event("points-#{id}", points)
{:ok, socket}
end
And then on the client:
Hooks.Chart = {
mounted(){
this.handleEvent(`points-${this.el.id}`, (points) => MyChartLib.addPoints(points));
}
}
Note: In case a LiveView pushes events and renders content, handleEvent callbacks are invoked after the page is updated. Therefore, if the LiveView redirects at the same time it pushes events, callbacks won't be invoked on the old page's elements. Callbacks would be invoked on the redirected page's newly mounted hook elements.
Note: If possible, construct commands via Elixir using Phoenix.LiveView.JS and trigger them via Phoenix DOM Bindings.
While Phoenix.LiveView.JS allows you to construct a declarative representation of a command, it may not cover all use cases.
In addition, you can execute commands that integrate with server DOM patching via JavaScript using:
this.js() or theliveSocket.js().The command interface returned by js() above offers the following functions:
show(el, opts = {}) - shows an element. Options: display, transition, time, blocking. For more details, see Phoenix.LiveView.JS.show/1.hide(el, opts = {}) - hides an element. Options: transition, time, blocking. For more details, see Phoenix.LiveView.JS.hide/1.toggle(el, opts = {}) - toggles the visibility of an element. Options: display, in, out, time, blocking. For more details, see Phoenix.LiveView.JS.toggle/1.addClass(el, names, opts = {}) - adds CSS class(es) to an element. Options: transition, time, blocking. For more details, see Phoenix.LiveView.JS.add_class/1.removeClass(el, names, opts = {}) - removes CSS class(es) to an element. Options: transition, time, blocking. For more details, see Phoenix.LiveView.JS.remove_class/1.toggleClass(el, names, opts = {}) - toggles CSS class(es) to an element. Options: transition, time, blocking. For more details, see Phoenix.LiveView.JS.toggle_class/1.transition(el, transition, opts = {}) - applies a CSS transition to an element. Options: time, blocking. For more details, see Phoenix.LiveView.JS.transition/1.setAttribute(el, attr, val) - sets an attribute on an elementremoveAttribute(el, attr) - removes an attribute from an elementtoggleAttribute(el, attr, val1, val2) - toggles an attribute on an element between two valuespush(el, type, opts = {}) - pushes an event to the server. To target a LiveComponent by its ID, pass a separate target in the options. Options: target, loading, page_loading, value. For more details, see Phoenix.LiveView.JS.push/1.navigate(href, opts = {}) - sends a navigation event to the server and updates the browser's pushState history. Options: replace. For more details, see Phoenix.LiveView.JS.navigate/1.patch(href, opts = {}) - sends a patch event to the server and updates the browser's pushState history. Options: replace. For more details, see Phoenix.LiveView.JS.patch/1.exec(encodedJS) - only via Client hook this.js(): executes encoded JS command in the context of the hook's root node. The encoded JS command should be constructed via Phoenix.LiveView.JS and is usually stored as an HTML attribute. Example: this.js().exec(this.el.getAttribute('phx-remove')).exec(el, encodedJS) - only via liveSocket.js(): executes encoded JS command in the context of any element.If you need to set element IDs from client-side JavaScript (for example, to auto-generate IDs for accessibility), you must use the js().setAttribute() method:
Hooks.MyHook = {
mounted() {
this.js().setAttribute(this.el, "id", "my-generated-id")
}
}
Setting IDs directly via node.id = "..." or other direct DOM manipulation methods will cause DOM patching issues. Always use js().setAttribute() instead.
If the server has already assigned an ID to an element, you cannot replace it with a different ID from the client side. Client-side IDs should only be set on elements that have no server-assigned ID.
Hooks (phx-hook) and Phoenix.LiveView.JS commands are not exclusive to LiveViews.
They also work on regular pages rendered by a normal Phoenix
controller — pages with no
live connection (sometimes referred to as dead views, dead renders,
static pages, or simply markup outside of a LiveView).
This includes markup that lives outside the live container on a page that does have a LiveView, such as your root layout — those elements belong to a body-level regular view, not to the LiveView.
To enable it, the page must load and connect LiveSocket, exactly as a LiveView page does:
import {LiveSocket} from "phoenix_live_view"
const liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks})
liveSocket.connect()
phx-hook — the mounted callback runs, and only for elements present at DOM
ready. The other callbacks (updated, beforeUpdate, destroyed, disconnected,
reconnected) are never invoked, because a regular view receives no updates from a server.phx-mounted — runs once the document is ready (DOMContentLoaded) and
liveSocket.connect() has been called (see Bindings).phx-click and other event bindings that trigger purely client-side JS
commands — for example JS.toggle/1, JS.show/1, JS.hide/1, JS.add_class/1,
JS.dispatch/1, and JS.transition/1. These execute entirely in the browser, so they
work with no server round-trip.JS.navigate/1 and JS.patch/1 — if the page has a connected LiveView (for
example a link in the root layout that sits outside the live container), they perform
normal live navigation against it. On a fully static page with no LiveView, they
gracefully fall back to a full-page browser navigation to the target URL.JS.push/1, form bindings (phx-change,
phx-submit), and other event bindings that push to the server have no LiveView
process to reach, so they have no effect.phx-connected and phx-disconnected bindings — they only take effect inside a
LiveView container and have no effect on a regular view.