IMPORTANT: It’s now possible to do LiveView JS interop with hooks, which make everything much simpler and smoother! I’ve published an updated version of the article: Phoenix LiveView JavaScript Hooks and Select2.
LiveView doesn’t support JavaScript interop at the moment (but it’s a planned feature). So, let’s see how we can come up with a workaround to make LiveView playing together with a JavaScript library like Select2.
Important: remember that LiveView is still in beta and that the workarounds we experiment in this article may not be ideal!
LiveView example with select
Let’s start by focusing on the select
element and consider this LiveView example
defmodule DemoWeb.CountriesLive do
use Phoenix.LiveView
@countries Countries.all() |> Enum.sort_by(&(&1.name))
def render(assigns) do
~L"""
<%= unless @count do %>
<button phx-click="show_count">Show Count</button>
<% else %>
<label><%= @count %></label>
<% end %>
<form phx-change="country_selected">
<select name="country" id="select_countries">
<option value="">None</option>
<%= for c <- @countries do %>
<option value="<%= c.alpha2 %>"
<%= selected_attr(@country, c) %>
>
<%= c.name %>
</option>
<% end %>
</select>
</form>
<%= if @country do %>
<h3><%= @country.name %></h3>
<!-- Google Maps iframe -->
<iframe src="http://maps.google.com/maps?q=<%= @country.name %>&output=embed"
width="360" height="270" frameborder="0" style="border:0"></iframe>
<% end %>
"""
end
def mount(_session, socket) do
socket =
socket
|> assign(:countries, @countries)
|> assign(:country, nil)
|> assign(:count, nil)
{:ok, socket}
end
def handle_event("country_selected", %{"country" => ""}, socket),
do: {:noreply, assign(socket,:country, nil)}
def handle_event("country_selected", %{"country" => code}, socket) do
country = Countries.filter_by(:alpha2, code) |> List.first()
{:noreply, assign(socket,:country, country)}
end
def handle_event("show_count", _, socket),
do: {:noreply, assign(socket, :count, Enum.count(@countries))}
defp selected_attr(country, country),
do: "selected=\"selected\""
defp selected_attr(_, _), do: ""
end
I’ve used countries which is a handy Elixir library to easily get the list of all countries with other useful informations.
LiveView renders a select
element with a list of countries. When we select a country, the front-end sends a country_selected
event to the server.
Using the browser’s inspector, we can see the messages between front-end and the Phoenix server. When we select a country, the browser sends a JSON message similar to this one
["1", "2", "lv:phx-bq7Z8gNV", "event",
{type: "form", event: "country_selected", value: "country=IT"}
]
The server handles the event with the handle_event("country_selected", %{"country" => code}, socket)
function, re-rendering the view and showing a Google maps iframe for the specific country.
The server sends back to the browser just the parts that need to be updated.
We can see the iframe
HTML code as part of the changes. Another important change is made to the select
options
<%= for c <- @countries do %>
<option value="<%= c.alpha2 %>"
<%= selected_attr(@country, c) %>
>
<%= c.name %>
</option>
<% end %>
The selected_attr(selected_country, country)
function adds a selected
attribute just for the option
element of the selected country.
When we select a new country, the server receives a country_selected
event and sends back the updated view to the browser, which then patches the option
elements.
Select2 and JavaScript events
We can make the country list a bit nicer, moving from the classic select element to the JavaScript Select2 library, which has a search box we can use to filter the countries.
Let’s start by adding jquery
and select2
to the assets/package.json
file of our Phoenix project, and download them running npm install
in the assets directory.
{
...
"dependencies": {
"phoenix": "../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"jquery": "3.4.1",
"select2": "4.0.7"
},
...
}
Then we import jQuery and Select2 in the assets/js/app.js
file, trying to initialize Select2 for the select element with id select_countries.
// assets/js/app.js
// liveSocket code...
// OUR CODE
import jQuery from "jquery"
import select2 from "select2"
window.jQuery = jQuery
jQuery(document).ready(function($){
function initSelect2() {
$("#select_num").select2()
}
initSelect2()
})
When the document is loaded we call the initSelect2
function, which should initialize select2. But… it doesn’t seem to work.
While refreshing the page, we see the new dropdown just for a fraction of a second – then LiveView re-renders the normal select
.
It works only when we try to initialize it manually using the browser’s console
//browser console
jQuery("#select_countries").select2()
In the LiveView code above, I’ve also added a Show Count button – when pressed, LiveView renders a label with the number of countries and the select2 dropdown is replaced with a normal select.
So… What’s happening here?
Every time an event is processed on the server, the view is re-rendered and all the changes are sent back to the browser. Then, the LiveView JavaScript library on the front-end, with the help of morphdom, patches the DOM to make the page equal to the one rendered on the server.
When we run jQuery("#select_countries").select2()
, the Select2 library changes the HTML on the page, adding the Select2 dropdown. Then, clicking on the Show Count button, the HTML is changed to match the one rendered on the server.
Hopefully we can use the phx:update
JavaScript event, which is dispatched every time LiveView updates the DOM – we can initialize select2 each time this event is dispatched.
// assets/js/app.js
jQuery(document).ready(function($){
function initSelect2() {
$("#select_countries").select2()
}
$(document).on("phx:update",initSelect2);
})
After refreshing the page, we see the Select2 dropdown; clicking the Show Count button doesn’t replace it with the normal select element. Great, it works 👍
We have to be careful though:phx:update
is a generic event that is dispatched for any kind of LiveView update, not necessarily related to the select tag.
It’s better then to initialize Select2 only when it really needs to
jQuery(document).ready(function($){
function initSelect2(selector) {
if (!isInitialized()) {
$(selector).select2()
}
function isInitialized() {
return $(selector).hasClass("select2-hidden-accessible")
}
}
$(document).on("phx:update", (e) => {
initSelect2("#select_countries")
});
})
To understand when Select2 is initialized, we use the fact that the library adds a select2-hidden-accessible class to the original select tag. The isInitialized()
function returns false
when it doesn’t find this class, meaning that Select2 has to be re-initialized.
Send events to the server
Unfortunately the new dropdown still doesn’t work properly – when we select a country, nothing happens. We need to find a way to send a country_selected event to the server, when a country is chosen.
phoenix_live_view.js is the LiveView JavaScript front-end library, it’s really clear and well documented. We can easily find a pushWithReply(event, payload) function, which is a method of the View
class.
At the beginning of our assets/js/app.js file, we initialize LiveSocket
// assets/js/app.js
import {LiveSocket, debug} from "phoenix_live_view"
let logger = function(kind, msg, data) {
// console.log(`${kind}: ${msg}`, data)
}
let liveSocket = new LiveSocket("/live", {logger: logger})
liveSocket.connect()
window.liveSocket = liveSocket
With window.liveSocket = liveSocket
we can access to it from the browser’s console.
We see that liveSocket
has a views
object – We can access to a view instance using the view id. We find this id
on the div tag that wraps the LiveView’s HTML.
Let’s now try to send an event to the server. We use jQuery to programmatically get the view id. The div tag is the parent of the only form
we have in the page
> var id = jQuery("form").parent().attr("id")
> id
"phx-kfRcGIP7"
With this id
we can now have access to the view instance and use call the pushWithReply(event, payload)
method. Remember the message the browser sent to the server when selecting a country?
[ ..., "event", {
type: "form",
event: "country_selected",
value: "country=IT"
}]
This is all we need to pass to pushWithReply()
. The "event"
string is the first argument, and the second argument
{
type: "form",
event: "country_selected",
value: "country=IT"
}
is the payload.
liveSocket.views[id]
.pushWithReply("event", {
type: "form",
event: "country_selected",
value: "country=IT"
})
It works! Now we just need to automatize it, calling this function when a Select2 option is selected.
//initSelect2 function
$(selector)
.select2()
.on("select2:select", (e)=>{
let viewId = $("form").parent().attr("id"),
countryCode = e.params.data.id;
liveSocket.views[viewId]
.pushWithReply("event", {
type: "form",
event: "country_selected",
value: `country=${countryCode}`
})
})
We listen to the select2:select
event and we get the country code from e.params.data.id
. We then push the event to the server passing the country=${countryCode}
value.
Considerations
When I find myself fighting with a tool to get the result I want, I tend to ask myself: is it the right tool for this job?
In a way, it’s very exciting to do experiments while looking for workarounds like these – it helps to better understand a tool and find temporary fixes.
But if we really need to use a JavaScript library (especially if it’s much more complex than Select2), it’s worth considering to just use Phoenix Channels, while waiting for JS interop in LiveView.